diff --git a/.docs/ADR-009-Port-Proof-API-Attestor-to-Go.md b/.docs/ADR-009-Port-Proof-API-Attestor-to-Go.md new file mode 100644 index 000000000..4642e33d0 --- /dev/null +++ b/.docs/ADR-009-Port-Proof-API-Attestor-to-Go.md @@ -0,0 +1,98 @@ +Here is the result of "view" for the Page with URL https://app.notion.com/p/3898c17818cb8030bcece5a4d3b55849 as of 2026-06-24T16:37:44.507Z: + + + + + + + +{"title":"ADR-009 Port Proof API + Attestor to Go"} + + + +## ADR-009: Port Proof API + Attestor to Go (vs keep Rust binary) +**Status:** `Proposed` +**Date:** 2026-06-23 +**Deciders:** +--- +### Context +We need to decide whether we should port the relevant V1 functionality of the rust Proof API + `ibc-attestor` binary into Go or whether IBC Link should execute parts of it in a rust subprocess. +A PoC exists that runs the Rust attestor under a Go host (via `go-plugin`) to explore how usable it is, whether we can achieve our goals of a unified YAML config surface and whether it meets our operational needs. It does not materially change the underlying reality that we are still shipping and executing a Rust binary. +### PoC +[GitHub - cosmos/ibc-attestor at go-plugin-poc](https://github.com/cosmos/ibc-attestor/tree/go-plugin-poc) +The PoC runs the rust ibc attestor as a go-plugin under a Go host and then fetches an attestation from the attestor, verifies logs are surfaced on the host, scrapes metrics, and intentionally kills and restarts the attestor subprocess. +**Configuration Surface** +The PoC demonstrates how we can still achieve the goals of providing a unified yaml configuration file for the ibc-link binary. The host program in the PoC takes the given yaml config and writes the attestor config values to a temp toml file in the form the attestor binary expects. The spawned attestor subprocess references the temp toml config file. The temporary file may present some friction in read-only deployment environments. +**Logging** +The PoC demonstrates that attestor logs are surfaced in the host process. Considerations here are: +- that go-plugin relies on stdout to perform a handshake between between the plugin and host and so the attestor routes all application logs to stderr in plugin mode. +- go-plugin relies on hclog for logging, so there would be a bit of additional work to make logs consistent if the host relies on a different logging library. +**Standalone Attestor Considerations** +One of the primary value props of go-plugin is the negotiation of a private connection between the plugin and the host. This benefit is moot when the attestor is run standalone as the attestor needs to bind on a fixed port that external traffic can be routed to. +**Attestor Binary** +The host process needs a reference to the attestor rust binary. The PoC assumes the attestor binary is in the same directory as the attestor binary. This is a detail we can hide in any containerized release artifact, but it still doesn’t change the underlying fact that we are shipping a rust binary in addition to the ibc-link binary. +**Attestor Code Additions** +Non-go plugins requires a bit more overhead to integrate than go plugins. The PoC adds \~150 LoC that: +- Adds a plugin mode +- Serves a grpc health service that go-plugin requires every gRPC plugin to serve +- Routes logs to stderr in plugin mode +- Binds to a free port +- Performs the plugin handshake with the host over stdout that announces what port it’s running on. +**Metrics** +The host process and subprocess will have different metrics registries which are scraped from different endpoints. Work would need to be done if wanting to provide a unified metrics endpoint. +**Takeaways** +- It’s pretty simple to run the attestor as a plugin and it serves our needs with some rough edges. +- It’s not some huge unlock. We can do something similar with standard libraries and some lightweight subprocess management logic. Given that running the attestor as sub-process alongside the relayer should only be used in PoC or testing environments, we have no need for some of the production oriented functionality that go-plugin provides like AutoMTLS, plugin host connection privacy, version negotation. +- Obvious but single binary is an illusion. There is still a rust binary that we must compile into any artifact we release and we will have toolchains for both rust and go. Local testing using the ibc-link binary will also rely on the attestor rust binary being compiled and we should provide make commands that handle this. +### Options Considered +### Option A: Run Rust logic in subprocess +- **Pros:** + - Minimal engineering investment + - Reuses audited code +- **Cons:** + - Still requires compiling and distributing a Rust artifact + - go-plugin adds constraints (stdout handshake, log routing to stderr, disjoint metrics registries, dependency on `hclog`) + - Increases debugging complexity + - Config surface must be bridged (YAML → temp TOML) and kept in sync + - Operational integration remains more complex than a native implementation. + - Requires both rust and go toolchain +### Option B: Port the relevant V1 Proof API + attestor logic to Go +- **Pros:** + - Aligns mission-critical code with Go (our preferred language) + - Eliminates Rust binary distribution and cross-language integration glue + - Simplifies debugging/ops + - Likely easiest to do now while scope is constrained + - Can use cosmos/kms signing libraries in attestor +- **Cons:** + - Higher upfront cost + - Must re-establish correctness, test coverage + - Requires internal audit of attestor + - Risk of regressions during port +### Decision +We should port the relevant parts of the Proof API and the attestor to Go for V1. +Primary justification: The V1 scope is limited (excluding Cosmos- and Solana-specific logic), and it will never be easier to do than it is now. Our previous discussion around language uncovered compelling reasons why we would want mission critical code to be in golang and I think if we don’t port it now it is unlikely to happen. The longer the rust logic is used in production the worse the tradeoff becomes as we will be trading production-tested code reviewed under the bug bounty program for brand new code. +Rough sizing estimate: Proof API port in \< 1.5 fte weeks; attestor port in \< 3 fte weeks. +Fallback: I don’t expect the port to be difficult, but if we run into unanticipated issues we can always fallback to the more minimal effort rust as subprocess route. +### Scope of Port +Proof-Api +- \~1.5k LoC +Aggregator (attestor fan-out / quorum) +- [mod.rs](http://mod.rs) +- [quorum.rs](http://quorum.rs) +- [attestor_](https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/aggregator/attestor_data.rs)[data.rs](https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/aggregator/attestor_data.rs) +- [config.rs](http://config.rs) +Attested EVM proof building
[https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/eth_attested.rs](https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/eth_attested.rs)
[https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/attestor.rs](https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/attestor.rs)
[https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/eth_eureka.rs](https://github.com/cosmos/solidity-ibc-eureka/blob/main/packages/proof-api/lib/src/utils/eth_eureka.rs) +Attestor +We are relying on everything except the cosmos and solana related logic in the V1. The complexity is concentrated in the files below with the rest of the service being pretty standard grpc service plumbing, configuration, and signer logic which we will be relying on cosmos/kms for. \~1.4k LoC total for the files below

[https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/rpc/attestor.rs](https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/rpc/attestor.rs)
[https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/attestation.rs](https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/attestation.rs)
[https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/attestation_payload.rs](https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/attestation_payload.rs)
[https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/adapter/evm.rs](https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/adapter/evm.rs) +### Tradeoffs Accepted +- We accept short-term delivery risk and increased testing/review work in exchange for a cleaner long-term architecture and lower operational complexity. +- We accept losing some immediate benefit of upstream Rust maturity in favor of consolidating language/runtime for mission-critical services. +### Consequences +- Engineering work: implement Go equivalents for the V1-required Proof API + attestor components; add compatibility tests against current behavior; audit. +--- +*Links: * +[GitHub - cosmos/ibc-attestor at go-plugin-poc](https://github.com/cosmos/ibc-attestor/tree/go-plugin-poc) +--- + +
+
\ No newline at end of file diff --git a/.docs/ADR-010-Attestor-Port-Spec.md b/.docs/ADR-010-Attestor-Port-Spec.md new file mode 100644 index 000000000..ef5f153e5 --- /dev/null +++ b/.docs/ADR-010-Attestor-Port-Spec.md @@ -0,0 +1,263 @@ +Here is the result of "view" for the Page with URL https://app.notion.com/p/38e8c17818cb80438ccfc4b2d3da8a47 as of 2026-07-03T04:07:35.300Z: + + + + + + + +{"title":"ADR-010 Attestor Port Spec"} + + +## ADR-010: Attestor Port Spec +**Status:** `Draft` +**Date:** 2026-06-29 +**Tags:** `infra` `relayer` `attestor` `evm` +--- +### Context +IBC Link needs a Go implementation of the attestor service. Initial support is limited to **EVM attestations**. The largest differences introduced in the port are in the configuration surface and the ability to attest to multiple chains from a single attestor process. There are not any significant changes otherwise to functionality required for IBC Link, so we can mostly port the existing Rust functionality one-to-one for EVM attestations. +The gRPC interface is slightly different from the Rust implementation. Requests must identify the target chain+signer pair with an `attestor` argument and there is some renaming for clarity. The request handlers must now route the request to the appropriate attestor, but otherwise the implementation should preserve the existing handlers’ functionality, validation, error responses, and semantics for EVM chains. +This ADR focuses on documenting the differences between the Go and existing Rust implementation and highlighting properties we should carry over in the port. +### Attestor Service Spec +#### Service Interface +```protobuf +syntax = "proto3"; + +package ibc_attestor; + +option go_package = "types/attestor"; + +// Service definition for retrieving attestations. +service AttestationService { + // Retrieves an attestation for a state at a given height. + rpc StateAttestation(StateAttestationRequest) returns (StateAttestationResponse); + + // Retrieves an attestation for a set of packets. + rpc PacketAttestation(PacketAttestationRequest) returns (PacketAttestationResponse); + + // Returns the latest height the attestor will generate attestations for. + rpc LatestAttestableHeight(LatestAttestableHeightRequest) returns (LatestAttestableHeightResponse); +} + +// Request message for getting an attestation for a state at a given height. +message StateAttestationRequest { + // Identifies which attestor to route the request to + string attestor = 1; + // The height to attest to. + uint64 height = 2; +} + +// Response message for getting an attestation for a state at a given height. +message StateAttestationResponse { + // The attestation. + Attestation attestation = 1; +} + +// Proof type for packet attestation. +enum ProofType { + // Packet commitment present in the attested chain's state (the packet's source). + PROOF_TYPE_PACKET_COMMITMENT = 0; + + // Acknowledgement present in the attested chain's state (the packet's destination). + PROOF_TYPE_PACKET_ACKNOWLEDGEMENT = 1; + + // Packet receipt absent from the attested chain's state (the packet's destination). + PROOF_TYPE_PACKET_RECEIPT_ABSENCE = 2; +} + +// Request message for getting an attestation for a set of packets. +// +// This request includes a height because packet attestations should be produced +// for a finalized, pinned chain height. +message PacketAttestationRequest { + // Identifies which attestor to route the request to + string attestor = 1; + + // The packets to attest to. + repeated bytes packets = 2; + + // The height to attest to the packets at. + uint64 height = 3; + + // The proof type to attest. + ProofType proof_type = 4; +} + +// Response message for getting an attestation for a set of packets. +message PacketAttestationResponse { + // The attestation. + Attestation attestation = 1; +} + +// Request message for getting the latest attestable height. +message LatestAttestableHeightRequest { + // Identifies which attestor to route the request to + string attestor = 1; +} + +// Response message for getting the latest attestable height. +message LatestAttestableHeightResponse { + // The latest attestable height of the attested chain. + uint64 height = 2; +} + +// Attestation is a single attestation from a given block height for the requested data. +message Attestation { + // The height of the attestation + uint64 height = 1; + // The timestamp of the block + optional uint64 timestamp = 2; + // The attested data + bytes attested_data = 3; + // The attestation signature + bytes signature = 4; +} +``` +#### Port Requirements +We describe some specific properties and invariants we should ensure we carry over below. +#### General Invariants +- State queries must be pinned at the request height. This prevents issues that can result from a sequence of RPC queries being served by different nodes at different heights in a load-balanced cluster. +- Integer operations must preserve the existing precautions around integer boundaries. + - For example, use [`saturating_sub`](https://github.com/cosmos/ibc-attestor/blob/main/apps/ibc-attestor/src/adapter/evm.rs#L112)-style behavior to prevent integer underflow. +- Requesting an attestation from an attestor that is not in the configs should result in a not found error. +#### By Endpoint +**`LatestAttestableHeight`** +- If a finality offset is configured, return `latest_height - finality_offset`. +- Otherwise, return the latest finalized block according to the EVM chain’s native finality definition. +**`StateAttestation`** +- Validate the requested height by checking whether it is finalized according to either: + - the configured finality offset, if configured, or + - the chain’s native finality. +- In the response `attested_data = abi(StateAttestation{ uint64 height, uint64 timestamp })` and the signature is generated over the message digest `sha256(0x01 ‖ sha256(attested_data))` +**`PacketAttestation`** +- Validate the requested height by checking whether it is finalized according to either: + - the configured finality offset, if configured, or + - the chain’s native finality. +- Bound the number of packets accepted in a request. +- If an attestation cannot be produced for any requested packet, reject the entire request. +- In the response `attested_data = abi(PacketAttestation { uint64 height, PacketCompact[] packets })` and the signature is generated over the message digest `sha256(0x02 ‖ sha256(attested_data))` +#### Signing +We should leverage `cosmos/kms` for local and remote signing. `cosmos/kms` currently implements a signer we can use for local signing where a key is loaded from a file. The implementation of a gRPC remote signing service is currently in progress. +**Keygen** +We can add the minimally necessary commands for keygen to unblock attestor development. Full keystore functionality will be specced out separately. +```shell +# generates a secp256k1 key +# writes it to a keyfile at the specified path +ibc keys add + +# reads keyfile and prints the eth address +# additionally prints hex private key if --show-private is set +ibc keys show --file --show-private +``` +**Signature Scheme** +Generate a 65-byte recoverable secp256k1 signature over: +```plain text +sha256(type_tag || sha256(attested_data)) +``` +Where `type_tag` is: + + + + + + + + + + + + + +
Attestation typeType tag
State attestation`0x01`
Packet attestation`0x02`
+`attested_data` is the ABI encoding of the relevant struct. +State attestation: +```solidity +struct StateAttestation { + uint64 height; + uint64 timestamp; +} +``` +Packet attestation: +```solidity +struct PacketAttestation { + uint64 height; + PacketCompact[] packets; +} + +struct PacketCompact { + bytes32 path; + bytes32 commitment; +} +``` +#### Architecture +The implementation should be abstracted so future chain types are easy to add. Using an adapter abstraction similar to the Rust implementation would be reasonable. We also need to introduce a new abstraction that takes an attestor identifier in requests and maps it to the correct chain adapter and signer. +#### Peripheral features +Implement the following to match the Rust implementation: +- prometheus metrics + - we should add additional labels identifying the relevant attestor +- tracing +- gRPC health service +- structured logging +- connectrpc +#### Configuration +There will be a block in the IBC Link configuration configuring the attestor as follows: +```yaml +attestors: + # each entry in the list represents an attestor that generates + # attestations for a unique chainId+signer pair. + - alias: "ethereum-attestor-0" # used to identify the target attestor in requests + chainId: "1" + signer: "my-attestor" # references a signer alias defined below + EVM: + routerAddress: "0x..." + finalityOffset: 1 # attest to heights <= latest height - finality offset; use native finality if unset +signers: + - alias: "my-attestor" + type: local + file: ~/.ibc/my-attestor.private-key.json +``` +### Options Considered +IBC Link will support running attestors in-process as part of the `ibc relayer` command and also running a standalone attestor using the `ibc attestor` command. The main design decision in the port where there may be contention around is whether we should enable the standalone attestor to support attesting multiple chains and signers. +### Option A: Preserve one chain per standalone attestor process +- **Pros:** + - Process isolation. + - Port is very straightforward. + - Minimizes attack surface. + - Keeps standalone deployment, monitoring, and failure modes easier to reason about. +- **Cons:** + - Requires running multiple standalone attestor processes in production contexts. + - Requires the relayer or deployment layer to manage multiple service endpoints. +### Option B: Support multiple chains and signers in a single standalone attestor process +Requires extending the existing attestor grpc endpoints to take an “id” argument representing a chain+signer pair. +- **Pros:** + - Reduces the number of standalone processes required. + - Allows a single service endpoint to expose attestations for multiple chains. + - Could simplify some operator workflows where all attestors are deployed together. +- **Cons:** + - A single process has access to keys/signer privileges for multiple chains + - Requires opening egress for a larger number of ports + - Larger attack surface +### Decision +~~Preserve **one chain per standalone attestor process**.~~ +~~Support multi-chain+signer attesting only for the relayer’s in-process attesting mode. Supporting in-process attesting for multiple chains and signers is straightforward if we have an attestor service that attests for a single chain and signer - the relayer can spawn one attestor service per chain+signer during startup, let each service choose a port at runtime, and inject the chosen ports or fully formed attestor clients into the relayer service.~~ +~~Supporting multiple chains in a standalone attestor is unnecessary scope. The relayer with in process attesting already satisfies the intended goal of simplifying the devex for users trying out IBC or building PoCs. In production where a standalone attestor is warranted, we should encourage process isolation and attack surface minimization. ~~ +Update: +Option B +This gives the most flexibility in attestor service topology at the cost of a bit more work. Operators who would like to opt for maximum security and attest to a single chain per attestor process may do so. Option A also added some complexity to the configuration surface where operators rely on an attestors array that is restricted to a single entry in standalone mode and unrestricted in relayer mode. Making the configuration consistent across modes will be easier for operators to understand. +### Tradeoffs Accepted +- Requires more work to support than option A. +- Attestor operators and relayers must now exchange attestor ids in addition to host information. +- Attestor operators are able to run multiple attestors in a single process now, which has security tradeoffs. A single process would have access to multiple keys/signers and have greater exfiltration risk due to the larger egress surface. +### Dissenting Views +: +I think we should add that `id` field to grpc and allow multiple chain attestations for a single attestor process (ensuring a single attestor address can only sign one chain). From the top of my head, I think the attack surface stays the same. Explanation (correct me I’m wrong). + + Let’s same there’s a zero-day vuln in attestors code base and the attacker knows that (the code is open source). The attacker doesn’t really care if they target 3 urls of `attestor-a.site.com` , `attestor-b.site.com`, [`attestor-c.site.com`](http://attestor-c.site.com) or a single deployment of `attestor.site.com?id=${id}` + +also, implementing multiple attestors in a single process leaves the door open for Dennis’es proposed deployment of 1 process/container per 1 attestor. +The security should come from using a remote KMS that is backed by some prod-ready backend like AWS HSM. +Another benefit of supporting multiple attestors is that a bank (B1) can attest its bridge to five other banks (B2-B5). In my view, each bank should run a single attestor process backed by two remote KMS keys and expose something like: +`grpc attestor.internal-vpc.chain.com?id={b1_to_b2}` +I understand that if, for example, an attestor crashes due to a bug, the bank essentially cannot sign both routes. For V1 we could implement Protobufs + gRPC, but explicitly mark two or more attestors as "not yet implemented" so that we can revisit this later without altering the contract +
+
\ No newline at end of file diff --git a/.docs/ADR-011-Relayer-Port-Spec.md b/.docs/ADR-011-Relayer-Port-Spec.md new file mode 100644 index 000000000..13b003682 --- /dev/null +++ b/.docs/ADR-011-Relayer-Port-Spec.md @@ -0,0 +1,449 @@ +Here is the result of "view" for the Page with URL https://app.notion.com/p/3908c17818cb80138984c24c0efba930 as of 2026-07-13T18:32:43.793Z: + + + + + + + +{"title":"ADR-011 Relayer Port Spec"} + + +## ADR-011: Relayer Port Spec +**Status:** `Draft` +**Date:** 2026-06-29 +**Tags:** `infra` `relayer` `evm` +--- +### Context +IBC Link needs a Go relayer, which we can largely reuse from the existing (Go) cosmos/ibc-relayer since the core relaying logic and pipeline are unchanged. This spec focuses on the changes required to bring the relayer into IBC Link rather than the relaying logic itself. The main work is: simplification of the relayer configs, an engine-neutral Store abstraction so the relayer runs on both Postgres and SQLite (mostly done already); consolidating the existing migrations into a single schema defined for both engines; optional migrate-on-startup; unifying signing across the relayer and attestor onto cosmos/kms behind a shared top-level signers config; and extracting the existing EVM tx-delivery reliability mechanisms into a single abstraction. Packet batching and the current tx-delivery guarantees are preserved. +### Config +The IBC Link config will contain a block for configuring the relayer that is formed as follows: + + Alternative structure is presented below in “Alternatives considered” + +```yaml + relayer: + port: ... + db: ... + chains: + - chainId: "1" + evm: + contracts: ... + txSubmissionDelay: 2s # delay between transaction submission attempts + gasFeeCapMultiplier: 1.5 + gasTipCapMultiplier: 1.5 + # configures gas balance thresholds where we surface a metric indicating the relayer has low gas balance + gasAlertThresholds: + warningThreshold: "500000000" + criticalThreshold: "10000000" + # lists the source clients the relayer monitors and relays transfers from + clients: + - id: "base-0" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-base" + - type: remote + grpc: .... + alias: "attestor-bob-base" + - type: remote + grpc: .... + alias: "attestor-clara-base" + - type: local + alias: "attestor-dan-base" + counterpartyChainID: "8453" + autoRelay: + enabled: true # enabled by default + lookback: 100 # number of blocks behind the latest that the relayer should start relaying transfers from + packetBatchSize: 20 # the maximum number of packets per relay tx on this chain + packetBatchTimeout: 10s # the maximum time the relayer waits for packets to collect for batching before relay + - chainId: "8453" + evm: + contracts: ... + txSubmissionDelay: 2s # delay between transaction submission attempts + gasFeeCapMultiplier: 1.5 + gasTipCapMultiplier: 1.5 + # configures gas balance thresholds where we surface a metric indicating the relayer has low gas balance + gasAlertThresholds: + warningThreshold: "500000000" + criticalThreshold: "10000000" + # lists the source clients the relayer monitors and relays transfers from + clients: + - id: "ethereum-0" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-ethereum" + - type: remote + grpc: .... + alias: "attestor-bob-ethereum" + - type: remote + grpc: .... + alias: "attestor-clara-ethereum" + - type: local + alias: "attestor-dan-ethereum" + counterpartyChainID: "1" + autoRelay: + enabled: true # enabled by default + lookback: 100 # number of blocks behind the latest that the relayer should start relaying transfers from + packetBatchSize: 20 # the maximum number of packets per relay tx on this chain + packetBatchTimeout: 10s # the maximum time the relayer waits for packets to collect for batching before relay + +``` +The config is different from the existing ibc relayer config in the following ways: +- Coingecko related configs are removed + - We currently rely on coingecko to convert gas costs to USD in the relayer. I don’t think this is actually useful to customers and we should just drop this functionality. The relayer doesn’t have a complete view of all the transactions submitted by an account since transactions may be submitted outside of the relayer. We currently also do the conversion based on the price at the time of packet relay, when an operator could be more interested in the cost based on present prices. +- Proof api configs removed +- The signing config is replaced with a top level signer config in the IBC link config that unifies signer configuration for both attestors and relayers. +- Single packet batching config instead of [separate batching configs](https://github.com/cosmos/ibc-relayer/blob/main/shared/config/config.go#L112-L151) for recv, ack and timeout +- Removed configs toggling [whether acks should be relayed](https://github.com/cosmos/ibc-relayer/blob/main/shared/config/config.go#L153-L157) +- `finalityOffset` is moved from a chain level config to the level of the attestor client configuration. This is a property of the attestor set used for a given attestations client and it can vary across attestations clients for the same chain. +- Adds details about the light client type and configuration. Since we are cutting out the proof-api, the relayer needs to know these details to form proofs. +- Chain rpcs moved out of relayer config and into separate chains config block at the top level of the IBC Link config +Any config removals will also involve removing the relevant logic. +#### Alternatives considered +The above config is very chain first. It has the downside of having many levels of nested configs. The below presents an alternative option that unbundles things a bit and separates out client specific configs, chain specific configs, and the routes to relay into separate configuration blocks at the same level. This also adds complexity as an operator needs to understand the dependencies between the three config blocks. +```yaml + relayer: + port: ... + db: ... + # chain specific relay configurations + chains: + - chainId: "1" + evm: + contracts: ... + txSubmissionDelay: 2s # delay between transaction submission attempts + gasFeeCapMultiplier: 1.5 + gasTipCapMultiplier: 1.5 + # configures gas balance thresholds where we surface a metric indicating the relayer has low gas balance + gasAlertThresholds: + warningThreshold: "500000000" + criticalThreshold: "10000000" + packetBatchSize: 20 # the maximum number of packets per relay tx on this chain + packetBatchTimeout: 10s # the maximum time the relayer waits for packets to collect for batching before relay + - chainId: "8453" + evm: + contracts: ... + txSubmissionDelay: 2s # delay between transaction submission attempts + gasFeeCapMultiplier: 1.5 + gasTipCapMultiplier: 1.5 + # configures gas balance thresholds where we surface a metric indicating the relayer has low gas balance + gasAlertThresholds: + warningThreshold: "500000000" + criticalThreshold: "10000000" + packetBatchSize: 20 # the maximum number of packets per relay tx on this chain + packetBatchTimeout: 10s # the maximum time the relayer waits for packets to collect for batching before relay + # lists relevant light client information + clients: + - clientId: "base-0" + chainId: "1" + counterpartyChainId: "8453" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-base" + - type: remote + grpc: .... + alias: "attestor-bob-base" + - type: remote + grpc: .... + alias: "attestor-clara-base" + - type: local + alias: "attestor-dan-base" + - clientId: "ethereum-0" + chainId: "8453" + counterpartyChainId: "1" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-ethereum" + - type: remote + grpc: .... + alias: "attestor-bob-ethereum" + - type: remote + grpc: .... + alias: "attestor-clara-ethereum" + - type: local + alias: "attestor-dan-ethereum" + # packets initiated from the source client identified below are relayed + # through the entire packet lifecycle - recv, ack, timeout + routesToRelay: + - sourceChainId: "1" + sourceClientId: "base-0" + autoRelay: + enabled: true # enabled by default + lookback: 100 # number of blocks behind the latest that the relayer should start relaying transfers from + - sourceChainID: "8453" + sourceCLientId: "ethereum-0" + autoRelay: + enabled: true # enabled by default + lookback: 100 # number of blocks behind the latest that the relayer should start relaying transfers from +``` +```yaml + (WIP) + relayer: + # chain specific overrides to relaying behavior + chainOverrides: + - chainId: "1" + evm: + txSubmissionDelay: 2s # delay between transaction submission attempts + packetBatchSize: 50 # the maximum number of packets per relay tx on this chain + packetBatchTimeout: 10s # the maximum time the relayer waits for packets to collect for batching before relay + - chainId: "8453" + evm: + gasFeeCapMultiplier: 1.1 + gasTipCapMultiplier: 1.1 + routes: + - relayAToB: true # defaults to true + relayBToA: false # defaults to true + autoRelayLookback: 100 # number of blocks behind the latest that the relayer should start relaying transfers from + clientA: + - clientId: "base-0" + chainId: "1" + counterpartyChainId: "8453" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-base" + - type: remote + grpc: .... + alias: "attestor-bob-base" + - type: remote + grpc: .... + alias: "attestor-clara-base" + - type: local + alias: "attestor-dan-base" + clientB: + - clientId: "ethereum-0" + chainId: "8453" + counterpartyChainId: "1" + type: "attestation" + attestorSet: + counterpartyChainFinalityOffset: 1 # relayer considers `latest block - finality offset` to be finalized + threshold: 3 + attestors: + - type: remote + grpc: .... + alias: "attestor-alice-ethereum" + - type: remote + grpc: .... + alias: "attestor-bob-ethereum" + - type: remote + grpc: .... + alias: "attestor-clara-ethereum" + - type: local + alias: "attestor-dan-ethereum" + +``` +### Database +The relayer currently supports postgres only and we are extending the relayer to support both postgres and sqlite. The current database abstraction does not make this straightforward to support as postgres details are coupled to the relaying logic in several places. [Here](https://github.com/cosmos/ibc-relayer/blob/c5df855368bca03ae4c7c21404f09742fcaf086e/relayer/ibcv2/check_send_finality.go#L63) is an example in the relaying pipeline where we reference postgres types. +The work done in this [PR](https://github.com/cosmos/ibc/pull/1254/changes#diff-0a585cc3a46679bd66270fbd9dfe50ac6ef6a406fe69724b5a5421f4b21090b2) introduces a generalized data abstraction for IBC Link that abstracts away details around what database type is used. We should extend the migrations implemented in this PR to support the schema and queries the relayer requires and then refactor the relayer to rely on the new `Store` abstraction. +#### Schema +The current relayer has a set of migrations defining the db schema [here](https://github.com/cosmos/ibc-relayer/tree/main/db/migrations). Since we are making a new relayer without any existing users we should consolidate the migrations into fewer files. We will also need to define the same schema in separate postgres and sqlite migration files. The target postgres schema is shown below. The sqlite schema can be inferred from the postgres schema. +```plain text +# Tracks packets and their relay state. The relayer tracks all transfers it must relay in this table +# and updates the columns as the packet progreses through relaying. +ibcv2_transfers + + Column | Type | Nullable | Default +------------------------------+-----------------------------+----------+--------------------------------------------- + id | integer | not null | nextval('ibcv2_transfers_id_seq'::regclass) + created_at | timestamp without time zone | not null | CURRENT_TIMESTAMP + updated_at | timestamp without time zone | not null | CURRENT_TIMESTAMP + status | ibcv2_relay_status | not null | 'PENDING'::ibcv2_relay_status + status_text | text | | + source_chain_id | text | not null | + destination_chain_id | text | not null | + source_tx_hash | text | not null | + source_tx_time | timestamp without time zone | not null | + packet_sequence_number | integer | not null | + packet_source_client_id | text | not null | + packet_destination_client_id | text | not null | + packet_timeout_timestamp | timestamp without time zone | not null | + recv_tx_hash | text | | + recv_tx_time | timestamp without time zone | | + write_ack_tx_hash | text | | + write_ack_tx_time | timestamp without time zone | | + ack_tx_hash | text | | + ack_tx_time | timestamp without time zone | | + timeout_tx_hash | text | | + timeout_tx_time | timestamp without time zone | | + write_ack_status | ibcv2_write_ack_status | | + recv_tx_relayer_address | text | | + ack_tx_relayer_address | text | | + timeout_tx_relayer_address | text | | + source_tx_finalized_time | timestamp without time zone | | + write_ack_tx_finalized_time | timestamp without time zone | | + +# Tracks source transactions that have been submitted to the /relay endpoint for relaying. +ibcv2_relay_requests + + Column | Type | Nullable | Default +-----------------+--------------------------+----------+----------------------------------------------------- + id | integer | not null | nextval('ibcv2_relay_submissions_id_seq'::regclass) + source_chain_id | text | not null | + source_tx_hash | text | not null | + created_at | timestamp with time zone | not null | now() + +# Tracks transactions that have been submitted by the relayer. The relayer tracks whether these transactions +# land on chain, whether execution succeeded, and what their gas costs were. +ibcv2_relayer_tx_submissions + + Column | Type | Nullable | Default +-----------------+------------------------------------+----------+---------------------------------------------------------- + id | integer | not null | nextval('ibcv2_relayer_tx_submissions_id_seq'::regclass) + tx_hash | text | not null | + chain_id | text | not null | + tx_type | ibcv2_relayer_tx_submission_type | not null | + relayer_address | text | not null | + submitted_at | timestamp with time zone | not null | + resolved_at | timestamp with time zone | | + gas_cost_amount | numeric | | + status | ibcv2_relayer_tx_submission_status | not null | + execution_error | text | | + +# Join table that associates ibcv2_relayer_tx_submissions rows with ibcv2_transfers rows. +# This lets us easily see what transactions were submitted to relay a particular packet. +ibcv2_transfer_tx_submissions + + Column | Type | Nullable | Default +---------------+---------+----------+--------- + transfer_id | integer | not null | + submission_id | integer | not null | +``` +#### Applying Migrations +The new `Store` abstraction introduces [helpers](https://github.com/cosmos/ibc/pull/1254/changes#diff-e9fdfb53df99b9b8f10a502cb865712fab26d79621bee02dd75dcbac5ebe7455R32-R36) to apply schema migrations. It also introduces a migrate cli command to apply migrations but the relayer should also support running migrations on startup conditional on a cli flag. This will simplify operation for many use cases and is also a requirement for running sqlite in memory. +```yaml +ibc relayer run --config config.yml # runs migrations on startup +ibc relayer run --config config.yml --no-migrate # skips migration on startup +``` +### Signing +The relayer currently supports a local and remote signing mode. It [reads private keys from a keyfile](https://github.com/cosmos/ibc-relayer/blob/c5df855368bca03ae4c7c21404f09742fcaf086e/cmd/relayer/main.go#L225-L242) when relying on local signing and it calls a [signer gRPC service](https://github.com/cosmos/ibc-relayer/blob/main/proto/signer/signerservice.proto) when relying on remote signing. The signing logic will be refactored and consolidated across the attestor and relayer leveraging [https://github.com/cosmos/kms](https://github.com/cosmos/kms) where possible. cosmos/kms already implements a local signer that reads a secp private key from a file and development is currently in progress on a gRPC [remote signing service](https://github.com/cosmos/kms/blob/main/proto/signerservice/signerservice.proto). Signers will be declared as a top level config as shown below. The shared signing libraries that construct local signers and remote signers for the attestor and relayer will be specced and implemented separately. +```yaml +relayer: + ... + signer: "my-relayer" + +attestors: + ... + signer: "my-attestor" + +signers: + - alias: "my-relayer" + type: local + file: ~/.ibc/my-relayer.private-key.json + + - alias: "my-attestor" + type: remote + grpc: ... +``` +### Tx Delivery +Transaction delivery can fail for a variety of reasons. For example transactions can be accepted by the node but then not get included due to spikes in gas price or they can fail to propagate through the network to a block proposer. The relayer currently relies on the following mechanisms to ensure reliable tx delivery on EVM chains. +- After submitting the transaction to the evm node, the relayer monitors for inclusion in a block. +- If the relayer fails to detect inclusion within some time threshold, it will create a new transaction to attempt to relay the packet again. +- Monitor the gap between the latest pending nonce and confirmed nonce for an account. If the gap exceeds some threshold, build the next transaction using the confirmed nonce instead of the pending nonce. This addresses an error condition where a sequence of pending transactions are blocked on one underpriced transaction that is in the mempool but failing to get included due to an insufficient fee. +We must preserve these tx delivery reliability mechanisms. The logic for the above is spread across several places in the relayer in [pipeline processors](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/retry_recv_packet.go) and the [bridge client](https://github.com/cosmos/ibc-relayer/blob/main/shared/bridges/ibcv2/evm_bridge_client.go). It is unlikely we have capacity for this, but ideally we would refactor this logic into a single tx delivery abstraction for cleaner separation of concerns and to make it easier for developers to reason about. We can model it after the tx manager abstractions found in the [OP bridge](https://pkg.go.dev/github.com/ethereum-optimism/optimism/op-service/txmgr) and [chainlink](https://pkg.go.dev/github.com/smartcontractkit/chainlink-evm/pkg/txm) relayers. +### Packet Batching +The relayer should support batched packet delivery. This is currently enabled by the [pipeline](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/pipeline.go) abstraction, a [batch processor type](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/conditional_batch_processor.go), and batch processor implementations for packet [receipt](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/batch_recv_packet.go), [acknowledgement](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/batch_ack_packet.go), and [timeout](https://github.com/cosmos/ibc-relayer/blob/main/relayer/ibcv2/timeout_packet.go). The existing abstractions will need to be modified slightly due to folding the proof api logic into the relayer and the simplifications made to the relay configuration around batching. +### Proof API +The relayer should no longer rely on the proof api for tx formation and we are porting over the relevant proof formation logic for EVM attestations. The [logic to be ported](/p/3898c17818cb8030bcece5a4d3b55849?pvs=25#3898c17818cb80599fa7eeed3be73208) should be fitted into the following abstractions: +- `ChainClient` parses packet details out of tx event logs. There will be an implementation per supported chain type and initially there will only be an implementation for EVM chains. +- `ProofGenerator` generates packet membership and non-membership proofs using the packet details identified by the `ChainClient` and also generates state proofs for client updates. There will be an implementation per client type. Initially there will only be an implementation for the attestations light client. It should have the following properties + - Attestor client aggregation should not experience liveness issues if any single attestor is unavailable or lagging in chain height if there is still a quorum of healthy attestors. + - If a proof cannot be generated for any of the packet inputs, an error should be returned instead of silently dropping packets from the result. +- `TxBuilder` builds txs given the packet relay and state update details identified by the other two abstractions. There will be an implementation per supported chain type and initially there will only be an implementation for EVM chains. It returns a list of transactions since Solana tx size limitations require dividing packet relays across multiple transactions. +```go +type Payload struct { + SourcePort, DestPort string + Version, Encoding string + Value []byte +} + +type Packet struct { + Sequence uint64 + SourceClient string + DestClient string + TimeoutTimestamp uint64 + Payloads []Payload +} + +type EventKind int // SendPacket, WriteAck + +type PacketEvent struct { + Height uint64 + Kind EventKind + Packet Packet + Acks [][]byte // one per payload, order preserved +} + +// Parses packet details out of packet event logs +type ChainClient interface { + TxPacketEvents(ctx context.Context, txHashes [][]byte) ([]PacketEvent, error) +} + +type ProofKind int // KindPacketCommitment, KindAcknowledgement, KindReceiptAbsence + +// Builds packet proofs and state proofs given packet details and target heights +type ProofGenerator interface { + // error if proof fails to be generated for any packet + PacketProofs(ctx context.Context, height uint64, kind ProofKind, packets []Packet) ([][]byte, error) + StateProof(ctx context.Context, height uint64) ([]byte, error) +} + +type RelayKind int // Recv, Ack, Timeout + +type PacketRelayItem struct { + Kind RelayKind + Packet Packet + Acks [][]byte // populated only for Kind == Ack + Proof []byte + ProofHeight uint64 +} + +type ClientUpdate struct { + ClientID string // client updated on the tx target chain + StateProof []byte +} + +type RelayTx struct { + To []byte + Data []byte +} + +// Converts state update and packet relay details into transactions to submit +type TxBuilder interface { + BuildRelayTxs(clientUpdate *ClientUpdate, packetRelayItems []PacketRelayItem) ([]RelayTx, error) +} +``` +### Auto-Relay +We will introduce auto-relay functionality for the relayer in IBC Link. This means it must monitor and relay all packets sent over configured connections. There is an implementation in the skip-go [relayer](https://github.com/skip-mev/relayer/tree/main/transfermonitor) we can model the IBC Link auto-relay functionality after. It performs event queries over a range of blocks making sure to examine every block from some start height. We may cut auto-relay from initial scope and the details of the IBC Link auto-relay implementation will be specced out separately. +We will initially port the relayer over with the /relay endpoint so development is not blocked on auto-relay. +### Dissenting Opinions + +The density of options in the config and the fact that there is relational, repeated entities makes me think that a giant config file is wrong for this and it should all just be written to the DB and have CRUD operations via an API. +e.g. CreateRelayer, CreateChain, UpdateAttestorSet, etc. +Reasons this makes more sense for me are: +- A config this large/complex with repeated sections will be easy to make mistakes in, harder to document than an API +- The data in this config is unlikely to change too much over time other than creation of new entities or removal of dead chains/clients + + \ No newline at end of file diff --git a/.docs/PRD-IBC-Link-Release.md b/.docs/PRD-IBC-Link-Release.md new file mode 100644 index 000000000..0ec386097 --- /dev/null +++ b/.docs/PRD-IBC-Link-Release.md @@ -0,0 +1,179 @@ +Here is the result of "view" for the Page with URL https://app.notion.com/p/3838c17818cb813bb803c9658aee5563 as of 2026-07-06T20:30:54.096Z: + + + + + + +{"title":"PRD — IBC Link Release"} + + +*Cosmos Labs · internal · Draft: 2026-06-30* +## Release +IBC is the best way to connect distributed ledgers, and we've heard the same thing from engineers, institutions, and our partners in our Interoperability Working Group: it's still too hard to adopt and fragmentation poses a large risk as disparate institutions adopt different technology stacks.

Our goal is making it easier to adopt and more widely accessible. That's what this release, and our submission to the Linux Foundation Decentralized Trust (LFDT), are about: making IBC the most widely available and easiest to use interoperability solution for DLTs. +This is the most usable and accessible IBC has been, and it reaches more venues. IBC Link stands up a live IBC connection in one step: it deploys the contracts, opens the connection, and runs the relaying and attestation from one binary and one config (Apache 2.0, so you can run it in production and in tests). Our attestor-based trust model widens where you can connect, reaching any system you need. + + **Besu is our anchor for V1.** It's the first framework. EVM L2s and EVM are also supported with more venues to come. + +### Features +- **One-step connection and contract deploy.** Blockchain operations engineers deploy the IBC contracts on each EVM chain (clients, the IFT token, GMP) and open the connection in one step, collapsing the 9+ manual deploy transactions EVM needs today. Besu is the first target. Docs: \[link TBD\]. +- **Cross-chain tokens and messages.** Token developers issue an IBC-native token (IFT) and move it between the two chains; application developers use GMP to make cross-chain contract calls. Docs: \[link TBD\]. +- **Self-running relaying.** One binary runs the relayer, attestor, and signer from one config, auto-relaying packets and handling proofs, submission, retries, and failure recovery behind a status API, with an attestor set as the trust model. SQLite by default for testing, Postgres optional for production, no separate database to operate. Docs: \[link TBD\]. +### Release notes, per artifact +All three ship together for the August 21, 2026 target. +- **IBC Link v1.0.0** (`cosmos/ibc`, new): one Go binary with the `ibc-link` CLI, bundling the relayer, attestor, and signer. One YAML config defines every connection; SQLite by default, Postgres optional. Apache 2.0. +- **ibc-go v12.0.0:** the IBC modules (new major line). +- **ibc-contracts v4.0.0** (`cosmos/ibc-contracts`, renamed from `solidity-ibc-eureka`): the Solidity contracts (clients, IFT token, GMP). +### Documentation (ships with the release) +- [ ] Deploy the IBC contracts and open an EVM-to-EVM connection in one step. +- [ ] Issue an IFT token and move it between the two chains. +- [ ] Make a cross-chain contract call with GMP. +- [ ] Run the relayer, attestor, and signer from one binary and config. +- [ ] Operate auto-relay: monitor the status API, retries, and failure recovery. +--- +### For +- **Interop adopters:** banks and enterprises (and the partners building for them) connecting EVM chains, who want it runnable without us and with minimal new infra. +- **Cross-ecosystem dev contributors:** EVM teams who want a reference path for enterprise-EVM interop. +- **LFDT:** this is the release behind our LFDT submission, the working proof that IBC is the open, easy-to-adopt interoperability standard we're putting forward. It carries the most weight with this audience. +**Status.** Dennis signed off on the relayer plan; the relayer build (the largest piece) is the next step. Target: August 21, 2026. Owners: Dennis (eng), Alex (product). OKRs: O1 KR2, O1 KR5. + + **LFDT announcement: Sibos 2026, Miami (September 28 to October 1).** We need everything to be ready for the LFDT well before that. + +**Not in V1, fast-follows.** Counterparty confidentiality; form factor (embeddable library, managed gateway); Cosmos and Solana connectivity; Tendermint light clients; ZK / SP1. Detail in the Living doc appendix. +--- +## Living doc +*Working detail for the engineers. The Release section up top is the source of truth and what we work back from; the requirements, decisions, and follow-ups live here, and this is where we iterate with eng.* +### Context +IBC Link lets a developer connect two EVM chains over IBC and move tokens or messages between them. You deploy the IBC contracts on each chain (clients, the IFT token contract, GMP), describe the connection in one config file, and run one binary that relays packets, builds the proofs, and submits the transactions; an attestor set signs each packet as the trust model. The headline use case is EVM to EVM, with Besu the first target (our LFDT anchor and reference deployment). +**What's net new vs. today:** existing IBC relayers target Cosmos and assume a connection you've already built by hand. IBC Link deploys the contracts, opens the connection, and reaches any EVM chain (Besu first). The 9+ manual deploy transactions for EVM today collapse to one step. +### Requirements (V1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PriorityUser storyStatusNotes
P0As a blockchain operations engineer, I deploy the IBC contracts (clients, IFT token, GMP) on each EVM chain and open the connection, so that two EVM chains can exchange packets without a hand-built deploy sequenceNot startedReplaces 9+ manual deploy txs for EVM. The broader CLI surface (query, config validate, db migrate) is eng's call, potential scope cut
P0As a blockchain operations engineer, I run the relayer, attestor, and signer from one binary and one config, so that I operate the whole stack as one serviceNot startedConsolidation mechanism (storage, etc.) is eng's to hash out
P0As a token developer, I issue an IBC-enabled token (IFT) and move it between the two chains, so that I have a working cross-chain tokenNot startedIFT = our interchain fungible token, IBC-native (not Axelar's ITS)
P0As a blockchain operations engineer, the binary auto-relays packets and handles proofs, submission, retries, and failure recovery with a status API, so that nothing gets stuck and I don't build an event loopNot started
P1As an application developer, I use GMP to make cross-chain contract calls between the two chains, so that I can build IBC apps beyond token transferNot startedGMP ships in the release; per Barry, not the priority story yet
P0As a blockchain operations engineer, I can run it in production and in tests because it's Apache 2.0Not startedToday's stack is source-available, which blocks production and third-party testing
+### Decisions adopted +- One core relaying solution: a single Go binary, one YAML, SQLite default, Postgres optional. +- Port the Rust components (ProofAPI, attestor) to Go. This is a rewrite and needs an internal security audit; the rationale is that team expertise is Go. +- Consolidate relayer and ProofAPI; built-in auto-relay; move the remote signer in-repo. +- Components as libraries wrapped in processes, so local and remote attestors and signers can mix. +- Attestor-based connections, not a zk light client (SP1 is out of V1). +- Apache 2.0. +- "IBC" means the current version; no legacy. +- Product name: IBC Link. +### Architecture +**Current state.** Relayer in `cosmos/platform` (Postgres state, gRPC-triggered relay, no auto-relay, no CLI). ProofAPI in Rust in `cosmos/ibc-contracts` (renamed from `solidity-ibc-eureka`; Tendermint sig+merkle, SP1 ZK, attestor signatures; uses Succinct SP1). Attestor in Rust in `cosmos/ibc-attestor` (queries a chain, signs packets; the relayer publishes aggregated signatures verified on-chain). Three config formats, 9+ deploy txs for EVM, manual wiring. Not self-serviceable. +**Target state.** One Go binary, one YAML config. The relayer absorbs ProofAPI aggregation in-process. SQLite default, Postgres optional. The platform's remote signer moves in-repo. The release centers on three repos: `cosmos/ibc` for the Go relayer, attestor, and signer, `cosmos/ibc-go` for the IBC modules, and `cosmos/ibc-contracts` for the Solidity contracts. Relayer, attestor, and signer are libraries wrapped in processes, which enables a combined mode (sandbox/PoC), an in-process attestor (a consortium member runs the relayer plus its own attestor in one binary), or a distributed consortium (remote attestors and signers). Detailed design lives in Dmitry's IBC Infra Revamp Proposal. +### CLI (`ibc-link`) +One binary drives the whole lifecycle. The config names every connection; connection-scoped commands take the connection explicitly, with no implicit "all," and you run one connection per process for failure isolation. +**Commands** +- `ibc-link init`: scaffold the YAML config. +- `ibc-link deploy `: deploy the IBC contracts (clients, IFT, GMP) for a connection. +- `ibc-link connect `: open the connection (deploy plus connect in one step). +- `ibc-link clients update `: create or refresh light clients. +- `ibc-link start `: run the relayer, attestor, and signer for one connection. Required argument, one connection per process. +- `ibc-link relay `: manual, one-shot relay of pending packets. +- `ibc-link status [connection]`: connection state, pending or stuck packets, retries, and quorum; one connection, or all if omitted. +- `ibc-link connections list | show `: inspect the connections this binary manages. +- `ibc-link chains add | list | rm`: manage chain endpoints. +- `ibc-link config validate`: validate the config. +- `ibc-link keys`: manage signing keys or point at a remote signer. +- `ibc-link attestor`: run or inspect the attestor and its quorum config. +- `ibc-link query `: query connection, packet, or client state. +- `ibc-link db-migrate`: migrate the SQLite or Postgres schema. +- `ibc-link version` +**Global flags:** `--config `, `--home `, `-o/--output text|json`, `--log-level`, `--log-format`, `-y/--yes`. +**Notable flags:** `start` takes `--mode combined|relayer|attestor`, `--api-addr` (bind for the HTTP API), and `--metrics-addr`; `status` takes `--api-addr` (target); `deploy` and `connect` take `--dry-run`; `db-migrate` takes `--to ` and `--dry-run`. +**Health:** the running daemon serves a `/health` endpoint on the API for liveness and readiness probes, so there is no separate CLI command; `status` is the operator-facing view. +**Scope note:** the broader surface (`query`, `config validate`, `chains`) and the `keys` / `attestor` quorum config may thin out for V1; the security items track the KMS, quorum, and rotation fast-follow in the appendix. +### Scope cuts (V1) +- No legacy IBC. +- No ZK / SP1 in the first iteration. +- No Cosmos-only or Solana connectivity in V1; EVM↔EVM via attestors is the focus, with Besu the first target. Solana is low-prio and mostly existing-ecosystem-driven. +- Tendermint light-client connections deferred (they need automated client updates the relayer doesn't do yet); V1 uses attestor-based connections, whose light clients don't expire. +- Counterparty confidentiality and form factor are fast-follows (appendix), not V1. +### Open questions +- Trust model: attestors generalize to any system, which is the inevitable goal; EVM chains are the V1 path, with Besu first. A native QBFT/IBFT light client would be a Besu-specific optimization for later. +- Performance and uptime SLOs, and the enterprise relayer + SLA definition (O1 KR2). +- **Peersyst (strategy, Alex):** the only team adopting IBC v2 institutionally is a non-EVM Cosmos chain. Does the consolidated relayer serve them without Besu, or are they a separate track? Reconcile with the Besu V1 focus. +### Appendix: sequenced follow-ups (not V1) +*Real work, deliberately deferred behind the Besu V1. Each toggle holds the detail.* +
+Counterparty confidentiality: fast-follow + In a hub-and-spoke topology (bank A connected to B, C, D, E), a transfer between A and B must not be observable by other connected parties. A hard requirement for bank adoption: the requirement is fixed, engineering decides the how. Deferred behind the Besu V1. + - As bank A, a transfer between me and bank B is not observable by C or anyone else connected to me (who transacted, how much, or that it happened). + - As a security lead, the payload (sender / receiver / amount / memo) is readable only by the counterparties, not the relayer or other connected parties. + - As a compliance officer, an authorized screening party sees exactly the data it must screen (sanctions / Travel Rule) and nothing broader. + Approaches to explore (engineering decides the how): + - **Transport / topology isolation:** a dedicated relayer per counterparty pair; no public RPC (mTLS / private networking); strictly bilateral clients; per-pair attestor sets. + - **Payload encryption:** encrypt the ICS-20 / GMP payload to the counterparty. IBC v2 only hashes the payload into the commitment and the light client only checks Merkle membership, so this needs no core protocol change, just a new encoding and key management. + - **Selective disclosure:** also encrypt to an authorized screening key so a designated party can screen while others stay blind. One option splits attestor roles: transport attestors sign the commitment and never see plaintext; a separate compliance attestor holds a scoped disclosure key. + - **Confidential amounts on-chain:** Pedersen commitments + range proofs (heavier; token-module change on both chains). + - **ZK-shielded transfers:** research-only; on a small two-party chain the anonymity set is tiny, so cost likely outweighs benefit. +
+
+Form factor & infrastructure footprint: fast-follow + Banks want applications, not infrastructure; they resist standing up and operating new services. The single binary is the first step. How far to push it is open, and engineering decides the how. + - As a bank integrating IBC, I add interoperability with the least possible new infrastructure, ideally no new standalone service to operate. + - As an application developer, I add IBC to an existing service as a software dependency rather than a separate deployment. + Approaches to explore: + - **Single self-contained binary** (already the plan): one service, not eight. + - **Embeddable library / SDK:** IBC relaying as a dependency inside a host app, no new process. Tradeoff: couples lifecycle and upgrades and can bring signing keys in-process, so it's better for dev/PoC than settlement-critical money movement. + - **Sidecar / signed container / appliance:** one image into existing orchestration; isolation and locality; language-agnostic host. + - **Managed gateway:** Cosmos Labs runs the infrastructure and the bank consumes an API (zero infra). The paid no-infra product, and a natural home for a compliance bundle. + - **Production caveat:** for settlement-critical value movement, failure isolation matters, so the binary or sidecar likely stays the production default while a library targets developers and PoCs. +
+
+Security infrastructure (KMS, quorum, rotation): fast-follow + Delegated signing and attestor quorum config, deferred behind the Besu V1. + - As a blockchain operations engineer, all signing (relayer txs and attestations) goes through our KMS via a bring-your-own remote signer with a published gRPC interface; local key files are dev-only. + - As a blockchain operations engineer, I configure an m-of-n attestor quorum threshold, enforced on-chain before any packet is accepted. + - As a blockchain operations engineer, I rotate relayer and attestor signing keys without service interruption. + Notes: the attestor supports remote signing today but it's undocumented; move the platform's remote signer in-repo; on-chain quorum exists, expose it via YAML. +
+### Related +- · roadmap hub (§7, workstream 2) +- · User Stories, item 1 +- · User Personas +- [Dmitry's IBC Infra Revamp Proposal](https://app.notion.com/p/37a8c17818cb8084a772d05ee2000426) + + \ No newline at end of file diff --git a/.github/workflows/link.yml b/.github/workflows/link.yml new file mode 100644 index 000000000..1d72dd7f6 --- /dev/null +++ b/.github/workflows/link.yml @@ -0,0 +1,97 @@ +name: Link and E2E + +on: + push: + branches: [main, development] + paths: + - "link/**" + - "e2e/**" + - "proto/**" + - "Makefile" + - ".github/workflows/link.yml" + pull_request: + branches: [main, development] + paths: + - "link/**" + - "e2e/**" + - "proto/**" + - "Makefile" + - ".github/workflows/link.yml" + +jobs: + generated: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: link/go.mod + cache: false + - run: go install github.com/bufbuild/buf/cmd/buf@v1.71.0 + - run: make check-proto + working-directory: link + + lint: + runs-on: ubuntu-latest + strategy: + matrix: + module: [link, e2e, e2e/internal/harness] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - uses: golangci/golangci-lint-action@v8 + with: + version: v2.12.2 + working-directory: ${{ matrix.module }} + + test: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - module: link + packages: ./... + - module: e2e + packages: ./e2etest ./internal/... + - module: e2e/internal/harness + packages: ./... + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - if: matrix.module == 'e2e/internal/harness' + run: make build-link + - if: matrix.module == 'e2e/internal/harness' + run: docker info + - if: matrix.module == 'e2e/internal/harness' + run: go test -count=1 ${{ matrix.packages }} + working-directory: ${{ matrix.module }} + env: + IBC_BIN: ${{ github.workspace }}/link/bin/ibc + - if: matrix.module != 'e2e/internal/harness' + run: go test ${{ matrix.packages }} + working-directory: ${{ matrix.module }} + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + lane: [anvil, besu] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: e2e/go.mod + cache-dependency-path: | + link/go.sum + e2e/go.sum + e2e/internal/harness/go.sum + - run: docker info + - run: make test-e2e E2E_LANE=${{ matrix.lane }} diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..8b13c314d --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +help: ## List repository commands + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +E2E_PKGS ?= ./setup ./external ./protocol +E2E_FLAGS ?= -count=1 +# Slow lanes (besu, anvil-interval) run real block times; the full protocol +# suite exceeds go test's default 10m timeout there. +E2E_TIMEOUT ?= 45m +E2E_LANE ?= anvil + +E2E_DIR := e2e +HARNESS_DIR := $(E2E_DIR)/internal/harness +SOLIDITY_IBC_CONTRACTS := $(HARNESS_DIR)/environment/solidityibc/contracts + +build-link: ## Build the Link binary + $(MAKE) -C link build + +doctor-e2e: ## Check the runtime dependencies used by e2e tests + @command -v go >/dev/null || { echo "missing go" >&2; exit 1; } + @command -v docker >/dev/null || { echo "missing docker; Docker is required for e2e lanes" >&2; exit 1; } + @command -v golangci-lint >/dev/null || { echo "missing golangci-lint; it is required for e2e checks" >&2; exit 1; } + @docker info >/dev/null || { echo "docker daemon is not reachable" >&2; exit 1; } + @if [ -n "$$IBC_BIN" ]; then test -x "$$IBC_BIN" || { echo "IBC_BIN is not executable: $$IBC_BIN" >&2; exit 1; }; echo "IBC_BIN=$$IBC_BIN"; else echo "IBC_BIN=link/bin/ibc (built by build-link)"; fi + +doctor-e2e-tools: ## Check the generation and lint tools used by repository e2e checks + @command -v forge >/dev/null || { echo "missing forge; Forge is required to build the Solidity IBC dependency contracts" >&2; exit 1; } + @command -v bun >/dev/null || { echo "missing bun; bun is required to install Solidity contract dependencies" >&2; exit 1; } + @command -v abigen >/dev/null || { echo "missing abigen; abigen is required to generate typed contract bindings" >&2; exit 1; } + @command -v jq >/dev/null || { echo "missing jq; jq is required to generate typed contract bindings" >&2; exit 1; } + @command -v golangci-lint >/dev/null || { echo "missing golangci-lint; it is required for e2e checks" >&2; exit 1; } + +test-harness: build-link ## Run harness tests, including Docker-backed integrations when available + go -C $(HARNESS_DIR) test ./... + +test-unit: ## Run pure-Go e2e selection and helper tests; no chains + go -C $(E2E_DIR) test ./e2etest ./internal/... + +test-e2e: build-link ## Run e2e tests (E2E_PKGS=... E2E_FLAGS=... E2E_LANE=...) + E2E_LANE=$(E2E_LANE) go -C $(E2E_DIR) test -timeout $(E2E_TIMEOUT) $(E2E_PKGS) $(E2E_FLAGS) + +lint-e2e: ## Lint the e2e and harness modules + cd $(E2E_DIR) && golangci-lint run + cd $(HARNESS_DIR) && golangci-lint run + +lint-fix-e2e: ## Lint the e2e and harness modules and fix errors + cd $(E2E_DIR) && golangci-lint run --fix + cd $(HARNESS_DIR) && golangci-lint run --fix + +clean-e2e-dry-run: ## Preview e2e processes and Docker resources + $(E2E_DIR)/scripts/clean.sh --dry-run + +clean-e2e: ## Kill e2e processes and remove Docker resources + $(E2E_DIR)/scripts/clean.sh + +test-apps: ## Rebuild Solidity IBC dependency artifacts and typed Go bindings (requires bun, forge, abigen, and jq) + bun install --cwd $(SOLIDITY_IBC_CONTRACTS) --frozen-lockfile + forge build --root $(SOLIDITY_IBC_CONTRACTS) + $(E2E_DIR)/scripts/generate-contract-bindings.sh + +check-test-apps: ## Fail if typed Go contract bindings are stale + bun install --cwd $(SOLIDITY_IBC_CONTRACTS) --frozen-lockfile + forge build --force --root $(SOLIDITY_IBC_CONTRACTS) + $(E2E_DIR)/scripts/generate-contract-bindings.sh + @git diff --exit-code -- $(HARNESS_DIR)/environment/solidityibc/accessmanager || { \ + echo "contract bindings are stale — run 'make test-apps' and commit the result" >&2; exit 1; } + +check-link: ## Run Link-local checks + $(MAKE) -C link check + +check-e2e: doctor-e2e test-harness test-unit lint-e2e test-e2e ## Run all repository e2e checks + +check: check-link check-e2e ## Run Link and repository e2e checks + +.PHONY: help build-link doctor-e2e doctor-e2e-tools test-harness test-unit test-e2e lint-e2e lint-fix-e2e \ + clean-e2e-dry-run clean-e2e test-apps check-test-apps check-link check-e2e check diff --git a/README.md b/README.md index cedb816c0..cd9ae4593 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ It shall be used to consolidate design rationale, protocol semantics, and encodi Contributions are welcome. See [CONTRIBUTING.md](spec/CONTRIBUTING.md) for contribution guidelines. +Repository-wide black-box acceptance tests live in [`e2e/`](e2e/README.md). + ## What is IBC? diff --git a/e2e/.golangci.yml b/e2e/.golangci.yml new file mode 100644 index 000000000..7a742a1fb --- /dev/null +++ b/e2e/.golangci.yml @@ -0,0 +1,67 @@ +# Lint config for the e2e module. It mirrors link/.golangci.yml except that it +# includes tests and disables revive's exported rule: this is test infrastructure, +# not a public library surface. +version: "2" + +run: + go: "1.26" + timeout: 30m + +issues: + max-same-issues: 50 + +formatters: + enable: [gci, gofmt, gofumpt, golines] + exclusions: + generated: lax + settings: + gci: + sections: + - "standard" + - "prefix(github.com/cosmos/ibc)" + - "default" + - "alias" + - "blank" # e.g. sql drivers + gofmt: + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + golines: + max-len: 120 + +linters: + enable: + - asciicheck + - bodyclose + - copyloopvar + - dogsled + - dupl + - goconst + - govet + - gocritic + - misspell + - nakedret + - nolintlint + - staticcheck + - unconvert + - unused + - revive + - errcheck + - errorlint + + settings: + dogsled: + max-blank-identifiers: 3 + misspell: + locale: US + govet: + enable: [shadow] + revive: + enable-default-rules: true + rules: + - name: package-comments + disabled: true + - name: exported + disabled: true + exclusions: + generated: lax diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 000000000..c718c1f92 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,22 @@ +# Repository E2E Tests Guide for AI Agents + +This module holds repository-level black-box e2e suites: linear Go tests that drive the real `ibc` +binary (`../link/bin/ibc`, built from `../link/cmd/ibc`) and real Solidity IBC contracts through the `internal/harness` surface, +asserting outcomes by reading chain state with the harness's own clients. + +- Run everything from the repository root: `make test-e2e` for the default anvil lane, or + `make test-e2e E2E_PKGS=./protocol E2E_FLAGS='-run TestIFT_RealRelayer_AutoRelay -count=1'` for one + loop. Lanes: `E2E_LANE=anvil|anvil-interval|besu`. +- Tests start an `Environment`, fund the relay signer, deploy the IBC apps (IFT/GMP) on both chains, + send real packets, and assert the real relayer's `/status` and the on-chain token effect. The + reference suite is `./protocol`; reuse its `fixture_test.go` helpers (`newFixture`, + `deployBridgedIFT`, `awaitPacketState`, `observePacketNeverTerminal`, `packetID`). +- **The wall:** the root e2e module is black-box infrastructure and must never import `link/internal`. + Learn real binary behavior by running it, not importing it. The binary under test is resolved by + `internal/harness/ibclink.ResolvedBin` (default `../link/bin/ibc`, overridable via `IBC_BIN`); + the wire contract it is held to lives in `internal/harness/ibclink/wire`. +- Each test gets a fresh `Environment`. Wait budgets come from the resolved Chain's + `environment.Timing`, never a literal tuned to instant Anvil. +- Capability-gated tests (mining/node control) call `e2etest.RequireCapabilities` before + `e2etest.Start`; Anvil-only tests call `e2etest.RequireAnvilLane`. +- After a hard crash: `make clean-e2e-dry-run`, then `make clean-e2e` from the repository root. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..70db9976f --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,115 @@ +# Repository E2E Test Surface + +This repository-level surface hosts black-box acceptance suites. They drive the real IBC Link binary +(`../link/bin/ibc`, built from `../link/cmd/ibc`) against real Solidity IBC contracts through the harness's public CLI, config, +readiness, relay, and status contracts, and corroborate outcomes by reading chain state directly. The +accepted harness design is documented in [IBC Environment Architecture](../link/HARNESS-ARCHITECTURE-DESIGN.md). + +`internal/harness/environment` realizes Chains and protocol resources (IBC instances, connections, +clients, attestors, and the `ibc relayer run` process). Tests then deploy the IFT/GMP applications and +send real packets, keeping funding, deployment, manual relay, fault injection, and teardown visible in +the behavior under test. + +## Suites + +| Suite | Package | What it proves | +| --- | --- | --- | +| Setup | `./setup` | `ibc config validate` structural and `--live` behavior (exit codes, located errors) against the real schema. | +| Protocol | `./protocol` | Real relayer IFT/GMP delivery, manual relay, timeouts/refunds, pending `/status` truthfulness, cross-route isolation, and restart/RPC-outage recovery. | +| External chain | `./external` | Relaying a real transfer through an attached RPC that `Environment` connects to but does not own. | + +## Running the suite + +Run targets from the repository root: + +```sh +make doctor-e2e +make build-link +make test-e2e +``` + +`make build-link` produces `link/bin/ibc`; `IBC_BIN` overrides that path. The harness starts the +binary as a black box and waits for its readiness line and `/health` before a test proceeds. + +The same tests can select different Chain declarations: + +- `make test-e2e` uses instant-mining Anvil for fast feedback. +- `make test-e2e E2E_LANE=anvil-interval` uses two-second Anvil blocks. +- `make test-e2e E2E_LANE=besu` uses Besu QBFT. +- `make test-e2e E2E_PKGS=./protocol E2E_FLAGS='-run TestIFT_RealRelayer_AutoRelay -count=1'` runs one + test repeatedly. + +`-e2e.lane` in `E2E_FLAGS` overrides `E2E_LANE`. Tests pinned to instant Anvil call +`e2etest.RequireAnvilLane(t)` so a matrix runs them only in that lane. After a hard crash, use +`make clean-e2e-dry-run` and then `make clean-e2e`. + +## Writing a test + +The reference suite is `./protocol`. Its `fixture_test.go` builds the standard two-chain, +single-connection lane and exposes the helpers most tests reuse: + +```go +func TestIFT_RealRelayer_AutoRelay(t *testing.T) { + fx := newFixture(t, true) // auto-relay on + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, SourceClient: conn.A().Locator(), + Receiver: receiver, Amount: amount, TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash) + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter)) +} +``` + +`Environment` owns Chain clients and protocol resources; an instance-bound app handle (`IFTApp`) hides only +application ABI, transaction, event, and state mechanics. The test keeps deployment, relayer status, +fault injection, and application assertions visibly ordered. + +Realization funds protocol authorities (e.g. the deployer) on managed Chains through their resolved +funding capability; the relay signer is funded by the test (`fundRelaySigner`). An attached Chain +cannot be funded by the harness — fund its authorities out of band before `Start`. + +Tests assess required controls before startup and then request the resolved capability: + +```go +e2etest.RequireCapabilities(t, fx.suite, environment.Requirements{ + MiningControl: []environment.ChainID{e2etest.ChainB}, +}) +env := e2etest.Start(t, fx.suite) + +chainB, err := env.Chain(e2etest.ChainB) +require.NoError(t, err) +mining, err := chainB.Mining() +require.NoError(t, err) +``` + +An invalid selection fails; an interchangeable selection that cannot guarantee a requirement skips +before acquisition. Startup failures always fail. + +## Extending the graph + +`e2etest.Suite` contains only an `environment.Spec` and its process-local runtime bindings. +`SelectedSuite(t)` supplies the ordinary two-Chain selection for the chosen lane, while exceptional +graphs use `e2etest.SuiteFor` directly (see `./external` for an attached-chain topology). A reusable +Environment constructor belongs in `e2etest` only when multiple tests need the same resource graph. diff --git a/e2e/e2etest/environment.go b/e2e/e2etest/environment.go new file mode 100644 index 000000000..205c53a14 --- /dev/null +++ b/e2e/e2etest/environment.go @@ -0,0 +1,30 @@ +package e2etest + +import ( + "testing" + + "github.com/cosmos/ibc/e2e/internal/harness/environment" +) + +// RequireCapabilities applies testing policy to the environment's pure +// assessment. Invalid selections fail the test; unavailable capabilities +// skip it before Environment.Start can acquire anything. +func RequireCapabilities( + t testing.TB, + selected Suite, + requirements environment.Requirements, +) { + t.Helper() + + assessment, err := environment.Assess( + selected.environmentSpec, + selected.environmentRuntime, + requirements, + ) + if err != nil { + t.Fatalf("e2etest: selected environment is invalid: %v", err) + } + if !assessment.Feasible() { + t.Skipf("e2etest: selected environment cannot satisfy test: %s", assessment) + } +} diff --git a/e2e/e2etest/start.go b/e2e/e2etest/start.go new file mode 100644 index 000000000..48b943ebf --- /dev/null +++ b/e2e/e2etest/start.go @@ -0,0 +1,173 @@ +// Package e2etest selects and starts reusable Environment configurations. +package e2etest + +import ( + "context" + "flag" + "os" + "strings" + "testing" + "time" + + "github.com/cosmos/ibc/e2e/internal/harness/environment" +) + +const ( + ChainA environment.ChainID = "chain-a" + ChainB environment.ChainID = "chain-b" +) + +const ( + cleanupTimeout = 30 * time.Second + laneEnv = "E2E_LANE" + + laneAnvil = "anvil" + laneAnvilInterval = "anvil-interval" + laneBesu = "besu" + + // Exported lane names for suites that build their own topology per lane. + LaneAnvilInterval = laneAnvilInterval + LaneBesu = laneBesu + + anvilChainIDBase = 31337 + anvilIntervalChainIDBase = 31437 + besuChainIDBase = 32337 + anvilIntervalBlockTime = 2 * time.Second +) + +var laneFlag = flag.String( + "e2e.lane", + "", + "e2e lane to run: anvil, anvil-interval, or besu; overrides E2E_LANE", +) + +// Suite is one reusable Environment selection. +type Suite struct { + environmentSpec environment.Spec + environmentRuntime environment.Runtime +} + +func SelectedSuite(t testing.TB) Suite { + t.Helper() + switch selectedLaneName(t) { + case laneBesu: + return twoBesuSuite() + case laneAnvilInterval: + return twoAnvilSuite(anvilIntervalChainIDBase, anvilIntervalBlockTime) + default: + return twoAnvilSuite(anvilChainIDBase, 0) + } +} + +func twoAnvilSuite(base uint64, interval time.Duration) Suite { + return suite( + []environment.ChainSpec{ + environment.ManagedAnvil{ID: ChainA, EVMChainID: base, BlockInterval: interval}, + environment.ManagedAnvil{ID: ChainB, EVMChainID: base + 1, BlockInterval: interval}, + }, + environment.Runtime{}, + ) +} + +func twoBesuSuite() Suite { + return suite( + []environment.ChainSpec{ + environment.ManagedBesu{ID: ChainA, EVMChainID: besuChainIDBase}, + environment.ManagedBesu{ID: ChainB, EVMChainID: besuChainIDBase + 1}, + }, + environment.Runtime{}, + ) +} + +// SuiteFor builds a selection for an explicit Environment. +func SuiteFor(spec environment.Spec, runtime environment.Runtime) Suite { + return Suite{ + environmentSpec: spec, + environmentRuntime: runtime, + } +} + +func suite(chains []environment.ChainSpec, runtime environment.Runtime) Suite { + return SuiteFor(environment.Spec{Chains: chains}, runtime) +} + +// SelectedLane returns the normalized selected lane name (the default anvil lane, +// LaneAnvilInterval, or LaneBesu) for suites that build their own Environment +// topology and need to vary chain specs or identities per lane. +func SelectedLane(t testing.TB) string { + t.Helper() + return selectedLaneName(t) +} + +// LaneChainSpec builds an environment-managed Chain spec of the type dictated by +// the selected lane: anvil -> instant-mining ManagedAnvil, anvil-interval -> +// ManagedAnvil at the lane's block interval, besu -> ManagedBesu. Callers own the +// EVM chain-id so distinct suites (and lanes) can avoid collisions. +func LaneChainSpec(t testing.TB, id environment.ChainID, evmChainID uint64) environment.ChainSpec { + t.Helper() + switch selectedLaneName(t) { + case laneBesu: + return environment.ManagedBesu{ID: id, EVMChainID: evmChainID} + case laneAnvilInterval: + return environment.ManagedAnvil{ID: id, EVMChainID: evmChainID, BlockInterval: anvilIntervalBlockTime} + default: + return environment.ManagedAnvil{ID: id, EVMChainID: evmChainID} + } +} + +// RequireAnvilLane deduplicates suites pinned to Anvil regardless of the +// selected lane. +func RequireAnvilLane(t testing.TB) { + t.Helper() + got := selectedLaneName(t) + if got != laneAnvil { + t.Skipf("runs only in the default anvil lane; selected %s", got) + } +} + +func selectedLaneName(t testing.TB) string { + t.Helper() + name := normalizeLaneName(rawLaneName()) + switch name { + case laneAnvil, laneAnvilInterval, laneBesu: + return name + default: + t.Fatalf( + "unknown e2e lane %q; set %s or -e2e.lane to anvil, anvil-interval, or besu", + rawLaneName(), + laneEnv, + ) + return "" + } +} + +func rawLaneName() string { + if strings.TrimSpace(*laneFlag) != "" { + return *laneFlag + } + return os.Getenv(laneEnv) +} + +func normalizeLaneName(name string) string { + trimmed := strings.ToLower(strings.TrimSpace(name)) + if trimmed == "" { + return laneAnvil + } + return trimmed +} + +func Start(t testing.TB, selected Suite) *environment.Environment { + t.Helper() + env, err := environment.Start(t.Context(), selected.environmentSpec, selected.environmentRuntime) + if err != nil { + t.Fatalf("e2etest: start Environment: %v", err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + if err := env.Close(ctx); err != nil { + t.Errorf("e2etest: close Environment: %v", err) + } + }) + return env +} diff --git a/e2e/e2etest/start_test.go b/e2e/e2etest/start_test.go new file mode 100644 index 000000000..8a212e559 --- /dev/null +++ b/e2e/e2etest/start_test.go @@ -0,0 +1,35 @@ +package e2etest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/environment" +) + +func TestSuiteSelectionsExposeOnlyGuaranteedCapabilities(t *testing.T) { + requirements := environment.Requirements{ + MiningControl: []environment.ChainID{ChainB}, + NodeLifecycle: []environment.ChainID{ChainB}, + } + + instant := twoAnvilSuite(anvilChainIDBase, 0) + assessment, err := environment.Assess(instant.environmentSpec, instant.environmentRuntime, requirements) + require.NoError(t, err) + require.True(t, assessment.Feasible()) + + interval := twoAnvilSuite(anvilIntervalChainIDBase, 2*time.Second) + assessment, err = environment.Assess(interval.environmentSpec, interval.environmentRuntime, requirements) + require.NoError(t, err) + require.False(t, assessment.Feasible()) + require.Equal(t, environment.Requirements{MiningControl: []environment.ChainID{ChainB}}, assessment.Missing(), + "interval Anvil retains node lifecycle but not manual mining") + + besu := twoBesuSuite() + assessment, err = environment.Assess(besu.environmentSpec, besu.environmentRuntime, requirements) + require.NoError(t, err) + require.False(t, assessment.Feasible()) + require.Equal(t, requirements, assessment.Missing()) +} diff --git a/e2e/external/external_test.go b/e2e/external/external_test.go new file mode 100644 index 000000000..da7462ec7 --- /dev/null +++ b/e2e/external/external_test.go @@ -0,0 +1,242 @@ +// Package external_test covers harness connectivity to an out-of-band chain the +// environment attaches to but does not own. +package external_test + +import ( + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/anvil" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// externalChainID differs from managedChainID so the two nodes are unambiguously +// distinct EVM chains. +const ( + managedChainID = 31337 + externalChainID = 31347 + externalEndpoint environment.EndpointBindingID = "external-chain-b" +) + +// Protocol identities for the single-Connection lane. Chain A is environment +// managed; chain B is the attached out-of-band node. +const ( + chainA = e2etest.ChainA + chainB = e2etest.ChainB + instanceA = environment.IBCInstanceID("ibc-a") + instanceB = environment.IBCInstanceID("ibc-b") + connID = environment.ConnectionID("a-b") + clientA = environment.ClientID("client-a") + clientB = environment.ClientID("client-b") + attestorA = environment.AttestorID("attestor-a") + attestorB = environment.AttestorID("attestor-b") + relayerID = environment.RelayerID("relayer-ab") + + deployer = environment.AuthorityID("deployer") + signerA = environment.AuthorityID("attestor-signer-a") + signerB = environment.AuthorityID("attestor-signer-b") + relaySigner = environment.AuthorityID("relay-signer") + + deployerKey = "0000000000000000000000000000000000000000000000000000000000000005" + signerAKey = "0000000000000000000000000000000000000000000000000000000000000001" + signerBKey = "0000000000000000000000000000000000000000000000000000000000000002" + relaySignerKey = "0000000000000000000000000000000000000000000000000000000000000003" + + tokenName = "Interop Fungible Token" + tokenSymbol = "IFT" +) + +// fundingMinimum is generous relative to the handful of transactions a single +// relayed transfer submits per chain. +var fundingMinimum = new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)) + +// TestExternalChain_EnvironmentConnectsButDoesNotOwn attaches an out-of-band +// Anvil as chain B and relays a real IFT transfer into it from a managed chain A: +// the environment must gain no ownership capability over the attached chain (no +// mining control), and environment teardown must leave the node running and +// serving. The harness cannot fund an attached chain, so the deployer and relay +// signer are funded out-of-band before the environment starts. +func TestExternalChain_EnvironmentConnectsButDoesNotOwn(t *testing.T) { + e2etest.RequireAnvilLane(t) + ctx := t.Context() + + oob, err := anvil.Start(ctx, anvil.Spec{ + ID: "chain-b-external", + ChainID: externalChainID, + LogPath: filepath.Join(t.TempDir(), "external-anvil.log"), + }) + require.NoError(t, err) + t.Cleanup(func() { + if stopErr := oob.Stop(); stopErr != nil { + t.Errorf("stop external Anvil: %v", stopErr) + } + }) + + startHeight, err := oob.Height(ctx) + require.NoError(t, err) + + // Realization funds managed-chain authorities but cannot fund an attached chain. + // The deployer (deploys IBC + IFT on chain B) and the relay signer (submits + // recvPacket on chain B) must therefore be funded out-of-band before start. + require.NoError(t, oob.EnsureEOABalance(ctx, addressOf(t, deployerKey), fundingMinimum)) + require.NoError(t, oob.EnsureEOABalance(ctx, addressOf(t, relaySignerKey), fundingMinimum)) + + auto := true + suite := e2etest.SuiteFor(environment.Spec{ + Chains: []environment.ChainSpec{ + environment.ManagedAnvil{ID: chainA, EVMChainID: managedChainID}, + environment.AttachedEVM{ + ID: chainB, EVMChainID: externalChainID, Endpoint: externalEndpoint, + Timing: environment.Timing{ + CompletionBudget: 60 * time.Second, + SettleWindow: 1500 * time.Millisecond, + PollInterval: 100 * time.Millisecond, + }, + }, + }, + IBCInstances: []environment.IBCInstanceSpec{ + environment.NewIBCInstance{ID: instanceA, Chain: chainA, Authority: deployer}, + environment.NewIBCInstance{ID: instanceB, Chain: chainB, Authority: deployer}, + }, + Connections: []environment.ConnectionSpec{{ + ID: connID, + A: environment.NewClient{ + ID: clientA, IBCInstance: instanceA, Authority: deployer, MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: clientB, IBCInstance: instanceB, Authority: deployer, MinRequiredSignatures: 1, + }, + }}, + Attestors: []environment.AttestorSpec{ + {ID: attestorA, Client: clientA, Authority: signerA}, + {ID: attestorB, Client: clientB, Authority: signerB}, + }, + Relayers: []environment.RelayerSpec{{ + ID: relayerID, Connections: []environment.ConnectionID{connID}, Authority: relaySigner, AutoRelay: &auto, + }}, + }, environment.Runtime{ + Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: deployerKey}, + relaySigner: {PrivateKeyHex: relaySignerKey}, + signerA: {PrivateKeyHex: signerAKey}, + signerB: {PrivateKeyHex: signerBKey}, + }, + Endpoints: map[environment.EndpointBindingID]environment.EndpointBinding{ + externalEndpoint: {RPCURL: oob.RPCURL()}, + }, + }) + + // The subtest owns the environment; its cleanup runs and completes before the + // out-of-band liveness probe below, or that probe would be vacuous. + t.Run("environment", func(t *testing.T) { + env := e2etest.Start(t, suite) + rctx := t.Context() + + // The relay signer needs gas on the managed chain too; realization funds only + // the protocol authorities, and the attached chain was funded out-of-band. + managed, chainErr := env.Chain(chainA) + require.NoError(t, chainErr) + funding, fundErr := managed.Funding() + require.NoError(t, fundErr) + require.NoError(t, funding.EnsureEOABalance(rctx, addressOf(t, relaySignerKey), fundingMinimum)) + + require.NoError(t, env.AuthorizePublicRelay(rctx, instanceA, deployer)) + require.NoError(t, env.AuthorizePublicRelay(rctx, instanceB, deployer)) + + iftA, deployErr := env.DeployIFT(rctx, environment.IFTRequest{ + Instance: instanceA, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, deployErr) + iftB, deployErr := env.DeployIFT(rctx, environment.IFTRequest{ + Instance: instanceB, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, deployErr) + + conn, connErr := env.Connection(connID) + require.NoError(t, connErr) + require.NoError(t, iftA.RegisterBridge(rctx, conn.A().Locator(), iftB.Address())) + require.NoError(t, iftB.RegisterBridge(rctx, conn.B().Locator(), iftA.Address())) + + deployerAddr, addrErr := env.AuthorityAddress(deployer) + require.NoError(t, addrErr) + receiver := environment.EVMAddress(newAddress(t).Hex()) + + amount := big.NewInt(1_500_000) + require.NoError(t, iftA.Mint(rctx, deployerAddr, amount)) + + transfer, transferErr := iftA.Transfer(rctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: uint64(time.Now().Add(time.Hour).Unix()), + }) + require.NoError(t, transferErr) + + attached, attachedErr := env.Chain(chainB) + require.NoError(t, attachedErr) + id := wire.RelayerPacketID(string(clientA), transfer.Sequence) + packet := awaitComplete(t, env, id, attached.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "the packet was delivered on the attached chain") + + destAfter, balErr := iftB.BalanceOf(rctx, receiver) + require.NoError(t, balErr) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited on the attached destination chain") + + // The environment merely connects to the attached chain: it must not advertise + // any ownership capability such as mining control over it. + mining, capErr := attached.Mining() + require.Nil(t, mining) + require.ErrorIs(t, capErr, environment.ErrCapabilityUnavailable, + "an attached Chain must not advertise mining control") + }) + + afterHeight, err := oob.Height(ctx) + require.NoError(t, err, "environment teardown must not have stopped the out-of-band external node") + require.GreaterOrEqual(t, afterHeight, startHeight, "external node kept running across environment teardown") +} + +// awaitComplete polls the relayer's keyed /status until the packet completes, +// failing on the completion budget or any /status error. +func awaitComplete(t *testing.T, env *environment.Environment, id string, timing environment.Timing) wire.PacketStatus { + t.Helper() + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + deadline := time.Now().Add(timing.CompletionBudget) + for time.Now().Before(deadline) { + status, statusErr := relayer.Status(t.Context(), wire.StatusQuery{PacketID: id}) + require.NoError(t, statusErr, "keyed /status must not error while a packet is transitioning") + if packet, ok := status.Packet(id); ok && packet.State == wire.PacketComplete { + return packet + } + time.Sleep(timing.PollInterval) + } + t.Fatalf("packet %s did not complete within %s", id, timing.CompletionBudget) + return wire.PacketStatus{} +} + +// addressOf derives the EVM address for a fixture private key so out-of-band +// funding can target it without holding an Environment handle. +func addressOf(t *testing.T, keyHex string) common.Address { + t.Helper() + key, err := crypto.HexToECDSA(keyHex) + require.NoError(t, err) + return crypto.PubkeyToAddress(key.PublicKey) +} + +// newAddress returns a fresh random EVM address for a transfer receiver. +func newAddress(t *testing.T) common.Address { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + return crypto.PubkeyToAddress(key.PublicKey) +} diff --git a/e2e/go.mod b/e2e/go.mod new file mode 100644 index 000000000..4cd17316a --- /dev/null +++ b/e2e/go.mod @@ -0,0 +1,88 @@ +module github.com/cosmos/ibc/e2e + +go 1.26.4 + +require ( + github.com/cosmos/ibc/e2e/internal/harness v0.0.0-00010101000000-000000000000 + github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b + github.com/ethereum/go-ethereum v1.17.4 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/fjl/jsonw v0.1.0 // indirect + golang.org/x/sync v0.21.0 // indirect +) + +require ( + connectrpc.com/connect v1.20.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cosmos/ibc/link v0.0.0 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/testcontainers/testcontainers-go v0.43.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.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 + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/cosmos/ibc/e2e/internal/harness => ./internal/harness + +replace github.com/cosmos/ibc/link => ../link diff --git a/e2e/go.sum b/e2e/go.sum new file mode 100644 index 000000000..22443bce8 --- /dev/null +++ b/e2e/go.sum @@ -0,0 +1,313 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523 h1:pQUezn45oyv/8VcOQvPCg2851EIIusY0YKkJhAXsy2I= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ= +github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0= +github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A= +github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7KTaA= +github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw= +github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g= +github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b h1:wst+2QsydHKoplWYY7V07OqxKyCmL59hjFsDLQoqZ5I= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b/go.mod h1:ndpS//nq2Ghin4UFdp0Cg4lrHZwK1VS/QRmi6DL9H+g= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y= +github.com/emicklei/dot v1.11.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +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.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= +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= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= +github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0= +github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= +github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k= +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= +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/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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +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/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +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= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/e2e/internal/harness/.golangci.yml b/e2e/internal/harness/.golangci.yml new file mode 100644 index 000000000..9cb51f6dd --- /dev/null +++ b/e2e/internal/harness/.golangci.yml @@ -0,0 +1,70 @@ +# Lint config for the e2e harness module. It mirrors link/.golangci.yml except +# that it includes tests, excludes repetitive test literals from goconst, and +# disables revive's exported rule for this test-infrastructure API. +version: "2" + +run: + go: "1.26" + timeout: 30m + +issues: + max-same-issues: 50 + +formatters: + enable: [gci, gofmt, gofumpt, golines] + exclusions: + generated: lax + settings: + gci: + sections: + - "standard" + - "prefix(github.com/cosmos/ibc)" + - "default" + - "alias" + - "blank" # e.g. sql drivers + gofmt: + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + golines: + max-len: 120 + +linters: + enable: + - asciicheck + - bodyclose + - copyloopvar + - dogsled + - dupl + - goconst + - govet + - gocritic + - misspell + - nakedret + - nolintlint + - staticcheck + - unconvert + - unused + - revive + - errcheck + - errorlint + + settings: + dogsled: + max-blank-identifiers: 3 + misspell: + locale: US + govet: + enable: [shadow] + revive: + enable-default-rules: true + rules: + - name: package-comments + disabled: true + - name: exported + disabled: true + exclusions: + generated: lax + rules: + - path: (.+)_test\.go + linters: [goconst] diff --git a/e2e/internal/harness/AGENTS.md b/e2e/internal/harness/AGENTS.md new file mode 100644 index 000000000..1a84b78ea --- /dev/null +++ b/e2e/internal/harness/AGENTS.md @@ -0,0 +1,21 @@ +# E2E Harness Development Guide for AI Agents + +This repository-internal module is the black-box e2e harness. Its Link adapter observes the `ibc` binary only through +its public wire surface — CLI commands with JSON output, config YAML, and the status API — and +corroborates outcomes by reading chain state with its own clients. + +- **The wall:** this module never imports `link/internal/...`. It may depend on the link module's + *public* API packages (`link/api/v2/...` typed request/response and readiness types, consumed by + the `ibclink` attestor runner); keep the dependency limited to that public surface. The single + declaration of the relayer wire contract is `ibclink/wire`; the binary under test is resolved by + `ibclink.ResolvedBin` (`IBC_BIN` override). +- Wait budgets derive from the resolved Chain's `environment.Timing`, never from a literal tuned + to instant Anvil. Launch-side readiness uses `chain/evm/poll.Until`; test-application effect and + stability observation lives with the e2e-only bindings that interpret those effects. +- **Docker discipline.** Containers/networks carry the `ibc-link-e2e=true` and + `ibc-link-run=` labels and the `ibc-link-e2e-` name prefix, with pinned images. Anvil runs + with `--entrypoint anvil` (PID 1, so `docker stop`'s SIGTERM reaches it and it dumps its + `--state` file — what makes StopNode/StartNode fault injection survive restarts). Don't + reintroduce a shell-wrapped entrypoint. +- Lint with `make lint-e2e` from the repository root; the harness uses `e2e/internal/harness/.golangci.yml`, which + mirrors the root config minus exported-doc mandates (everything here is internal surface). diff --git a/e2e/internal/harness/chain/capabilities.go b/e2e/internal/harness/chain/capabilities.go new file mode 100644 index 000000000..06fc851ad --- /dev/null +++ b/e2e/internal/harness/chain/capabilities.go @@ -0,0 +1,28 @@ +package chain + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type BlockController interface { + MineBlocks(ctx context.Context, n int) error + PauseMining(ctx context.Context) error + ResumeMining(ctx context.Context) error + AdvanceTime(ctx context.Context, d time.Duration) error +} + +type FaultInjector interface { + StopNode(ctx context.Context) error + StartNode(ctx context.Context) error +} + +// EOAFunder guarantees a verified minimum native-token balance for an +// externally owned account. Managed Chain adapters implement the mechanism +// without exposing their funding identities or controls to workflow callers. +type EOAFunder interface { + EnsureEOABalance(ctx context.Context, address common.Address, minimum *big.Int) error +} diff --git a/e2e/internal/harness/chain/chain.go b/e2e/internal/harness/chain/chain.go new file mode 100644 index 000000000..1e41d064b --- /dev/null +++ b/e2e/internal/harness/chain/chain.go @@ -0,0 +1,9 @@ +package chain + +import "context" + +type Chain interface { + ID() string + RPCURL() string + Height(ctx context.Context) (uint64, error) +} diff --git a/e2e/internal/harness/chain/evm/accounts.go b/e2e/internal/harness/chain/evm/accounts.go new file mode 100644 index 000000000..7793df1e5 --- /dev/null +++ b/e2e/internal/harness/chain/evm/accounts.go @@ -0,0 +1,51 @@ +// Package evm contains shared ethclient-backed account, signing, and client helpers. +package evm + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +type Account struct { + key *ecdsa.PrivateKey + addr common.Address +} + +func AccountFromHex(hexKey string) (Account, error) { + key, err := crypto.HexToECDSA(strings.TrimPrefix(hexKey, "0x")) + if err != nil { + return Account{}, fmt.Errorf("parse private key: %w", err) + } + return accountFromKey(key), nil +} + +func NewAccount() (Account, error) { + key, err := crypto.GenerateKey() + if err != nil { + return Account{}, fmt.Errorf("generate key: %w", err) + } + return accountFromKey(key), nil +} + +func accountFromKey(key *ecdsa.PrivateKey) Account { + return Account{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} +} + +func (a Account) Address() common.Address { return a.addr } + +func (a Account) TransactOpts(chainID *big.Int) (*bind.TransactOpts, error) { + if a.key == nil { + return nil, fmt.Errorf("account has no private key") + } + opts, err := bind.NewKeyedTransactorWithChainID(a.key, chainID) + if err != nil { + return nil, fmt.Errorf("create transactor: %w", err) + } + return opts, nil +} diff --git a/e2e/internal/harness/chain/evm/accounts_test.go b/e2e/internal/harness/chain/evm/accounts_test.go new file mode 100644 index 000000000..5dd7e72d9 --- /dev/null +++ b/e2e/internal/harness/chain/evm/accounts_test.go @@ -0,0 +1,50 @@ +package evm + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testPrivateKeyHex = "0000000000000000000000000000000000000000000000000000000000000001" + +func TestAccountFromHexDerivesAddress(t *testing.T) { + acct, err := AccountFromHex(testPrivateKeyHex) + require.NoError(t, err) + assert.Equal(t, common.HexToAddress("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"), acct.Address()) +} + +func TestAccountFromHexAcceptsPrefix(t *testing.T) { + withPrefix, err := AccountFromHex("0x" + testPrivateKeyHex) + require.NoError(t, err) + withoutPrefix, err := AccountFromHex(testPrivateKeyHex) + require.NoError(t, err) + assert.Equal(t, withoutPrefix.Address(), withPrefix.Address()) +} + +func TestTransactOptsRecoversSender(t *testing.T) { + acct, err := AccountFromHex(testPrivateKeyHex) + require.NoError(t, err) + chainID := big.NewInt(31337) + to := common.HexToAddress("0x0000000000000000000000000000000000000001") + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + To: &to, + Value: big.NewInt(1), + Gas: 21_000, + GasFeeCap: big.NewInt(1_000_000_000), + GasTipCap: big.NewInt(1_000_000_000), + }) + + opts, err := acct.TransactOpts(chainID) + require.NoError(t, err) + signed, err := opts.Signer(acct.Address(), tx) + require.NoError(t, err) + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + assert.Equal(t, acct.Address(), sender) +} diff --git a/e2e/internal/harness/chain/evm/anvil/anvil.go b/e2e/internal/harness/chain/evm/anvil/anvil.go new file mode 100644 index 000000000..abf0193b1 --- /dev/null +++ b/e2e/internal/harness/chain/evm/anvil/anvil.go @@ -0,0 +1,470 @@ +package anvil + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "math/big" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" + "github.com/testcontainers/testcontainers-go" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/container" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/poll" + + chainpkg "github.com/cosmos/ibc/e2e/internal/harness/chain" + containertypes "github.com/moby/moby/api/types/container" +) + +const ( + DefaultDockerImage = "ghcr.io/foundry-rs/foundry:v1.7.1" + DockerImageEnv = "ANVIL_DOCKER_IMAGE" + + anvilGasLimit = 50_000_000 + anvilReadyTimeout = 30 * time.Second + // Terminal shutdown leaves time for Anvil to flush its --state file. + anvilStopGrace = 5 * time.Second + anvilStopTimeout = 15 * time.Second + anvilLogTimeout = 10 * time.Second + + containerStateDir = "/anvil-state" + anvilStartupTailBytes = 4096 + pollInterval = 50 * time.Millisecond +) + +type Spec struct { + ID string + ChainID uint64 + LogPath string + RunID string + Image string + // StatePath stores chain state on graceful container shutdown; empty derives it from LogPath. + StatePath string + + // BlockTime > 0 seals blocks via --block-time (whole seconds; rounded, rejected if it rounds + // to zero); 0 keeps Anvil in instant/automine mode. + BlockTime time.Duration +} + +type Chain struct { + client *evm.EVMClient + evm.Identity + + logPath string + + spec Spec + container testcontainers.Container + + clientMu sync.RWMutex + + mu sync.Mutex + stopped bool + closed bool + + fundingMu sync.Mutex +} + +var ( + _ chainpkg.Chain = (*Chain)(nil) + _ chainpkg.EOAFunder = (*Chain)(nil) +) + +func DockerImage() string { + if image := os.Getenv(DockerImageEnv); image != "" { + return image + } + return DefaultDockerImage +} + +func Start(ctx context.Context, spec Spec) (*Chain, error) { + if spec.ID == "" { + return nil, errors.New("anvil chain id is empty") + } + if spec.ChainID == 0 { + return nil, fmt.Errorf("anvil chain %s: EVM chain id is required", spec.ID) + } + var err error + if spec.StatePath == "" { + spec.StatePath, err = deriveStatePath(spec) + if err != nil { + return nil, err + } + } + spec, stateContainerPath, mountSpec, err := normalizeDockerSpec(spec) + if err != nil { + return nil, err + } + + container, rpcURL, ec, err := launchAnvil(ctx, spec, stateContainerPath, mountSpec) + if err != nil { + return nil, err + } + + return &Chain{ + client: ec, + Identity: evm.NewIdentity(spec.ID, rpcURL), + logPath: spec.LogPath, + spec: spec, + container: container, + }, nil +} + +func launchAnvil( + ctx context.Context, + spec Spec, + stateContainerPath, mountSpec string, +) (testcontainers.Container, string, *evm.EVMClient, error) { + args := []string{ + "--port", "8545", + "--host", "0.0.0.0", + "--chain-id", strconv.FormatUint(spec.ChainID, 10), + "--gas-limit", strconv.Itoa(anvilGasLimit), + "--state", stateContainerPath, + // Managed chains use explicit signers. Keeping Anvil's deterministic accounts disabled + // avoids creating ambient identities, and quiet mode keeps its mnemonic out of diagnostics. + "--accounts", "0", + "--quiet", + } + if spec.BlockTime > 0 { + secs := uint64(math.Round(spec.BlockTime.Seconds())) + if secs == 0 { + return nil, "", nil, fmt.Errorf( + "anvil (chain %s): block time %v is below the 1s granularity of --block-time", + spec.ID, + spec.BlockTime, + ) + } + args = append(args, "--block-time", strconv.FormatUint(secs, 10)) + } + + request := testcontainers.ContainerRequest{ + Name: containerName(spec), + Image: spec.Image, + Entrypoint: []string{"anvil"}, + Cmd: args, + ExposedPorts: []string{"8545/tcp"}, + Labels: container.Labels(spec.RunID), + HostConfigModifier: func(config *containertypes.HostConfig) { + container.BindPortsToLoopback(config, "8545/tcp") + config.Mounts = append(config.Mounts, mount.Mount{ + Type: mount.TypeBind, + Source: mountSpec, + Target: containerStateDir, + }) + }, + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: request, + }) + if err != nil { + return nil, "", nil, fmt.Errorf("create anvil container (chain %s): %w", spec.ID, err) + } + if startErr := container.Start(ctx); startErr != nil { + startErr = fmt.Errorf("start anvil container (chain %s): %w", spec.ID, startErr) + return nil, "", nil, errors.Join(startErr, cleanupFailedStart(container)) + } + host, err := container.Host(ctx) + if err != nil { + return nil, "", nil, errors.Join(fmt.Errorf("resolve anvil host: %w", err), cleanupFailedStart(container)) + } + port, err := container.MappedPort(ctx, "8545/tcp") + if err != nil { + return nil, "", nil, errors.Join( + fmt.Errorf("resolve anvil RPC port: %w", err), + cleanupFailedStart(container), + ) + } + rpcURL := fmt.Sprintf("http://%s:%s", host, port.Port()) + + ec, err := connectAnvil(ctx, spec, rpcURL) + if err != nil { + startupErr := anvilStartupError(container, err) + return nil, "", nil, errors.Join(startupErr, cleanupFailedStart(container)) + } + return container, rpcURL, ec, nil +} + +func connectAnvil(ctx context.Context, spec Spec, rpcURL string) (*evm.EVMClient, error) { + // HTTP dialing is lazy and succeeds on an unbound port; readiness is probed separately below. + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, fmt.Errorf("dial anvil (chain %s): %w", spec.ID, err) + } + + probe := func(ctx context.Context) error { + _, blockErr := client.BlockNumber(ctx) + return blockErr + } + if readyErr := waitReady(ctx, probe, anvilReadyTimeout); readyErr != nil { + client.Close() + return nil, fmt.Errorf("anvil (chain %s) readiness: %w", spec.ID, readyErr) + } + + return evm.NewVerifiedClient(ctx, client, spec.ChainID, fmt.Sprintf("anvil (chain %s)", spec.ID)) +} + +func (ac *Chain) WithEVMClient(use func(*evm.EVMClient) error) error { + ac.clientMu.RLock() + defer ac.clientMu.RUnlock() + if ac.client == nil { + return fmt.Errorf("anvil chain %s: EVM client is unavailable", ac.ID()) + } + return use(ac.client) +} + +func (ac *Chain) Height(ctx context.Context) (uint64, error) { + var height uint64 + err := ac.WithEVMClient(func(client *evm.EVMClient) error { + var err error + height, err = client.Height(ctx) + return err + }) + return height, err +} + +func (ac *Chain) replaceEVMClient(client *evm.EVMClient) { + ac.clientMu.Lock() + defer ac.clientMu.Unlock() + if ac.client != nil { + ac.client.Close() + } + ac.client = client +} + +func (ac *Chain) closeEVMClient() { + ac.clientMu.Lock() + defer ac.clientMu.Unlock() + if ac.client != nil { + ac.client.Close() + ac.client = nil + } +} + +// EnsureEOABalance uses Anvil's development control to set and then verify the +// requested minimum without exposing that control through the resolved Chain. +func (ac *Chain) EnsureEOABalance(ctx context.Context, address common.Address, minimum *big.Int) error { + if minimum == nil || minimum.Sign() < 0 { + return fmt.Errorf("anvil ensure EOA balance: minimum must be non-nil and non-negative") + } + + ac.fundingMu.Lock() + defer ac.fundingMu.Unlock() + + return ac.WithEVMClient(func(client *evm.EVMClient) error { + if err := client.RequireEOA(ctx, address); err != nil { + return fmt.Errorf("anvil ensure EOA balance: %w", err) + } + current, err := client.Client().BalanceAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("anvil query balance for %s: %w", address, err) + } + if current.Cmp(minimum) >= 0 { + return nil + } + if setErr := client.RPCClient().CallContext( + ctx, + nil, + "anvil_setBalance", + address, + hexutil.EncodeBig(minimum), + ); setErr != nil { + return fmt.Errorf("anvil set balance for %s: %w", address, setErr) + } + got, err := client.Client().BalanceAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("anvil verify balance for %s: %w", address, err) + } + if got.Cmp(minimum) < 0 { + return fmt.Errorf("anvil verify balance for %s: got %s, want at least %s", address, got, minimum) + } + return nil + }) +} + +func normalizeDockerSpec(spec Spec) (Spec, string, string, error) { + if spec.Image == "" { + spec.Image = DockerImage() + } + if spec.RunID == "" { + spec.RunID = fmt.Sprintf("manual-%d-%d", os.Getpid(), time.Now().UnixNano()) + } + statePath, err := filepath.Abs(spec.StatePath) + if err != nil { + return Spec{}, "", "", fmt.Errorf("absolute anvil state path (chain %s): %w", spec.ID, err) + } + stateDir := filepath.Dir(statePath) + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return Spec{}, "", "", fmt.Errorf("create anvil state dir %s: %w", stateDir, err) + } + spec.StatePath = statePath + containerPath := path.Join(containerStateDir, filepath.Base(statePath)) + return spec, containerPath, stateDir, nil +} + +func containerName(spec Spec) string { + return container.NamePrefix(spec.RunID, spec.ID) + "-anvil" +} + +func waitReady(ctx context.Context, probe func(context.Context) error, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var lastErr error + err := poll.Until(ctx, pollInterval, func(ctx context.Context) (bool, error) { + lastErr = probe(ctx) + return lastErr == nil, nil + }) + if err != nil { + return fmt.Errorf("not ready after %s: %w (last probe: %w)", timeout, err, lastErr) + } + return nil +} + +func anvilStartupError(container testcontainers.Container, err error) error { + ctx, cancel := context.WithTimeout(context.Background(), anvilLogTimeout) + defer cancel() + logs := dockerLogs(ctx, container) + if logs == "" { + return err + } + return fmt.Errorf("%w\npartial anvil logs:\n%s", err, evm.Tail(logs, anvilStartupTailBytes)) +} + +func cleanupFailedStart(container testcontainers.Container) error { + ctx, cancel := context.WithTimeout(context.Background(), anvilStopTimeout) + defer cancel() + return container.Terminate(ctx, testcontainers.StopTimeout(anvilStopGrace)) +} + +func deriveStatePath(spec Spec) (string, error) { + if spec.LogPath == "" { + return "", fmt.Errorf( + "anvil (chain %s): spec needs StatePath or LogPath (the --state file is derived from LogPath)", + spec.ID, + ) + } + return strings.TrimSuffix(spec.LogPath, ".log") + ".state.json", nil +} + +func (ac *Chain) CollectLogs(ctx context.Context) map[string]string { + log := ac.snapshotLogs(ctx) + if log == "" { + return nil + } + return map[string]string{ac.ID(): log} +} + +func (ac *Chain) Stop() error { + ac.mu.Lock() + if ac.closed && ac.container == nil { + ac.mu.Unlock() + return nil + } + if !ac.closed { + ac.closeEVMClient() + ac.closed = true + } + container := ac.container + alreadyStopped := ac.stopped + ac.mu.Unlock() + + var stopErr error + if alreadyStopped { + resumeCtx, cancel := context.WithTimeout(context.Background(), anvilStopTimeout) + stopErr = resumeContainer(resumeCtx, container) + cancel() + } + if stopErr == nil { + stopCtx, cancel := context.WithTimeout(context.Background(), anvilStopTimeout) + stopErr = stopContainer(stopCtx, container) + cancel() + } + ac.snapshotLogs(context.Background()) + rmCtx, cancel := context.WithTimeout(context.Background(), anvilStopTimeout) + rmErr := removeContainer(rmCtx, container) + cancel() + if rmErr == nil { + ac.mu.Lock() + ac.stopped = true + ac.container = nil + ac.mu.Unlock() + return nil + } + return errors.Join(stopErr, rmErr) +} + +func (ac *Chain) snapshotLogs(ctx context.Context) string { + logCtx, cancel := context.WithTimeout(ctx, anvilLogTimeout) + defer cancel() + + log := dockerLogs(logCtx, ac.container) + if log != "" && ac.logPath != "" { + _ = os.WriteFile(ac.logPath, []byte(log), 0o644) + } + return log +} + +func dockerLogs(ctx context.Context, container testcontainers.Container) string { + if container == nil { + return "" + } + reader, err := container.Logs(ctx) + if err != nil { + return "" + } + defer reader.Close() //nolint:errcheck + data, err := io.ReadAll(reader) + if err != nil { + return "" + } + return string(data) +} + +func stopContainer(ctx context.Context, container testcontainers.Container) error { + if container == nil || !container.IsRunning() { + return nil + } + grace := anvilStopGrace + return container.Stop(ctx, &grace) +} + +func removeContainer(ctx context.Context, container testcontainers.Container) error { + if container == nil { + return nil + } + return container.Terminate(ctx) +} + +func pauseContainer(ctx context.Context, container testcontainers.Container) error { + docker, err := client.New(client.FromEnv) + if err != nil { + return err + } + defer docker.Close() //nolint:errcheck + _, err = docker.ContainerPause(ctx, container.GetContainerID(), client.ContainerPauseOptions{}) + return err +} + +func resumeContainer(ctx context.Context, container testcontainers.Container) error { + docker, err := client.New(client.FromEnv) + if err != nil { + return err + } + defer docker.Close() //nolint:errcheck + _, err = docker.ContainerUnpause(ctx, container.GetContainerID(), client.ContainerUnpauseOptions{}) + return err +} diff --git a/e2e/internal/harness/chain/evm/anvil/anvil_test.go b/e2e/internal/harness/chain/evm/anvil/anvil_test.go new file mode 100644 index 000000000..7ad1553ce --- /dev/null +++ b/e2e/internal/harness/chain/evm/anvil/anvil_test.go @@ -0,0 +1,43 @@ +package anvil + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" +) + +func TestStopSucceedsWhenRemovalSucceedsAfterGracefulStopFailure(t *testing.T) { + container := &stopTestContainer{stopErr: errors.New("graceful stop failed")} + chain := &Chain{container: container} + + require.NoError(t, chain.Stop()) + require.True(t, chain.closed) + require.True(t, chain.stopped) + require.Nil(t, chain.container) + require.Equal(t, 1, container.terminateCalls) +} + +type stopTestContainer struct { + testcontainers.Container + stopErr error + terminateCalls int +} + +func (*stopTestContainer) IsRunning() bool { return true } + +func (c *stopTestContainer) Stop(context.Context, *time.Duration) error { return c.stopErr } + +func (c *stopTestContainer) Terminate(context.Context, ...testcontainers.TerminateOption) error { + c.terminateCalls++ + return nil +} + +func (*stopTestContainer) Logs(context.Context) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} diff --git a/e2e/internal/harness/chain/evm/anvil/controls.go b/e2e/internal/harness/chain/evm/anvil/controls.go new file mode 100644 index 000000000..9652679b2 --- /dev/null +++ b/e2e/internal/harness/chain/evm/anvil/controls.go @@ -0,0 +1,65 @@ +package anvil + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/cosmos/ibc/e2e/internal/harness/chain" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" +) + +var _ chain.BlockController = (*Chain)(nil) + +func (ac *Chain) MineBlocks(ctx context.Context, n int) error { + if n <= 0 { + return nil + } + return ac.WithEVMClient(func(client *evm.EVMClient) error { + if err := client.RPCClient().CallContext(ctx, nil, "anvil_mine", hexutil.Uint64(n)); err != nil { + return fmt.Errorf("anvil_mine %d: %w", n, err) + } + return nil + }) +} + +func (ac *Chain) PauseMining(ctx context.Context) error { + return ac.WithEVMClient(func(client *evm.EVMClient) error { + if err := client.RPCClient().CallContext(ctx, nil, "evm_setAutomine", false); err != nil { + return fmt.Errorf("evm_setAutomine false: %w", err) + } + return nil + }) +} + +func (ac *Chain) ResumeMining(ctx context.Context) error { + return ac.WithEVMClient(func(client *evm.EVMClient) error { + if err := client.RPCClient().CallContext(ctx, nil, "evm_setAutomine", true); err != nil { + return fmt.Errorf("evm_setAutomine true: %w", err) + } + return nil + }) +} + +func (ac *Chain) AdvanceTime(ctx context.Context, d time.Duration) error { + // evm_increaseTime has whole-second granularity; positive durations rounding to zero are rejected. + if d < 0 { + return fmt.Errorf("AdvanceTime: duration %v must be non-negative", d) + } + secs := uint64(math.Round(d.Seconds())) + if d > 0 && secs == 0 { + return fmt.Errorf("AdvanceTime: duration %v is below the 1s granularity of evm_increaseTime", d) + } + return ac.WithEVMClient(func(client *evm.EVMClient) error { + if err := client.RPCClient().CallContext(ctx, nil, "evm_increaseTime", secs); err != nil { + return fmt.Errorf("evm_increaseTime %ds: %w", secs, err) + } + if err := client.RPCClient().CallContext(ctx, nil, "anvil_mine", hexutil.Uint64(1)); err != nil { + return fmt.Errorf("apply time advance: anvil_mine 1: %w", err) + } + return nil + }) +} diff --git a/e2e/internal/harness/chain/evm/anvil/faults.go b/e2e/internal/harness/chain/evm/anvil/faults.go new file mode 100644 index 000000000..a8c402839 --- /dev/null +++ b/e2e/internal/harness/chain/evm/anvil/faults.go @@ -0,0 +1,54 @@ +package anvil + +import ( + "context" + "errors" + "fmt" + + "github.com/cosmos/ibc/e2e/internal/harness/chain" +) + +var _ chain.FaultInjector = (*Chain)(nil) + +func (ac *Chain) StopNode(ctx context.Context) error { + ac.mu.Lock() + defer ac.mu.Unlock() + if ac.stopped { + return nil + } + // Keep the client open so calls observe the paused node; StartNode replaces it. + if err := pauseContainer(ctx, ac.container); err != nil { + return fmt.Errorf("stop anvil node %s: %w", ac.ID(), err) + } + ac.stopped = true + return nil +} + +func (ac *Chain) StartNode(ctx context.Context) error { + ac.mu.Lock() + defer ac.mu.Unlock() + if !ac.stopped { + return nil + } + if err := resumeContainer(ctx, ac.container); err != nil { + return fmt.Errorf("restart anvil node %s: %w", ac.ID(), err) + } + ec, err := connectAnvil(ctx, ac.spec, ac.RPCURL()) + if err != nil { + startupErr := fmt.Errorf("restart anvil node %s: %w", ac.ID(), anvilStartupError(ac.container, err)) + stopCtx, cancel := context.WithTimeout(context.Background(), anvilStopTimeout) + stopErr := pauseContainer(stopCtx, ac.container) + cancel() + if stopErr != nil { + // The container may still be running, so StopNode and terminal cleanup + // must be allowed to retry the stop. + ac.stopped = false + return errors.Join(startupErr, fmt.Errorf("stop anvil after failed restart: %w", stopErr)) + } + return startupErr + } + ac.replaceEVMClient(ec) + ac.closed = false + ac.stopped = false + return nil +} diff --git a/e2e/internal/harness/chain/evm/attached.go b/e2e/internal/harness/chain/evm/attached.go new file mode 100644 index 000000000..879a835a8 --- /dev/null +++ b/e2e/internal/harness/chain/evm/attached.go @@ -0,0 +1,48 @@ +package evm + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/ethclient" + + chainpkg "github.com/cosmos/ibc/e2e/internal/harness/chain" +) + +type AttachedSpec struct { + ID string + ChainID uint64 + RPCURL string +} + +type AttachedChain struct { + *EVMClient + Identity +} + +var _ chainpkg.Chain = (*AttachedChain)(nil) + +// The caller must close the returned connection. +func ConnectAttached(ctx context.Context, spec AttachedSpec) (*AttachedChain, error) { + if spec.ID == "" { + return nil, errors.New("attached chain id is empty") + } + if spec.ChainID == 0 { + return nil, fmt.Errorf("attached chain %s: chain id is required to verify the node", spec.ID) + } + if spec.RPCURL == "" { + return nil, fmt.Errorf("attached chain %s: rpc url is empty", spec.ID) + } + + client, err := ethclient.DialContext(ctx, spec.RPCURL) + if err != nil { + return nil, fmt.Errorf("dial attached chain %s at %s: %w", spec.ID, spec.RPCURL, err) + } + ec, err := NewVerifiedClient(ctx, client, spec.ChainID, fmt.Sprintf("attached chain %s", spec.ID)) + if err != nil { + return nil, err + } + + return &AttachedChain{EVMClient: ec, Identity: NewIdentity(spec.ID, spec.RPCURL)}, nil +} diff --git a/e2e/internal/harness/chain/evm/besu/besu.go b/e2e/internal/harness/chain/evm/besu/besu.go new file mode 100644 index 000000000..8b37c9b4a --- /dev/null +++ b/e2e/internal/harness/chain/evm/besu/besu.go @@ -0,0 +1,540 @@ +package besu + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/moby/moby/api/types/mount" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/container" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/poll" + + chainpkg "github.com/cosmos/ibc/e2e/internal/harness/chain" + containertypes "github.com/moby/moby/api/types/container" +) + +const ( + DefaultDockerImage = "hyperledger/besu:26.5.0" + DockerImageEnv = "BESU_DOCKER_IMAGE" + + besuEpochLength = 30000 + besuReadyTimeout = 90 * time.Second + besuStopTimeout = 30 * time.Second + besuLogTimeout = 10 * time.Second + besuStartupLogTailBytes = 4096 + besuTreasuryBalanceHex = "0xc9f2c9cd04674edea40000000" +) + +type Spec struct { + ID string + ChainID uint64 + WorkDir string + RunID string + Image string + // BlockPeriod is rounded to whole seconds and rejected if it rounds to zero. + BlockPeriod time.Duration +} + +type Chain struct { + *evm.EVMClient + evm.Identity + + container testcontainers.Container + logID string + treasury evm.Account + wait evm.TransactionWait + + mu sync.Mutex + stopped bool + closed bool + + fundingMu sync.Mutex +} + +var ( + _ chainpkg.Chain = (*Chain)(nil) + _ chainpkg.EOAFunder = (*Chain)(nil) +) + +func DockerImage() string { + if image := os.Getenv(DockerImageEnv); image != "" { + return image + } + return DefaultDockerImage +} + +func StartQBFT(ctx context.Context, spec Spec) (result *Chain, err error) { + if spec.ID == "" { + return nil, errors.New("besu chain id is empty") + } + if spec.ChainID == 0 { + return nil, fmt.Errorf("besu chain %s: EVM chain id is required", spec.ID) + } + if spec.Image == "" { + spec.Image = DockerImage() + } + if spec.RunID == "" { + return nil, fmt.Errorf("besu chain %s: run id is empty", spec.ID) + } + if spec.WorkDir == "" { + return nil, fmt.Errorf("besu chain %s: work dir is empty", spec.ID) + } + treasury, err := evm.NewAccount() + if err != nil { + return nil, fmt.Errorf("besu chain %s: create funding treasury: %w", spec.ID, err) + } + periodSecs := int(math.Round(spec.BlockPeriod.Seconds())) + if periodSecs <= 0 { + return nil, fmt.Errorf( + "besu chain %s: block period %v is below the 1s granularity of QBFT blockperiodseconds", + spec.ID, + spec.BlockPeriod, + ) + } + + chainDir, err := filepath.Abs(spec.WorkDir) + if err != nil { + return nil, fmt.Errorf("absolute besu work dir: %w", err) + } + if mkdirErr := os.MkdirAll(chainDir, 0o777); mkdirErr != nil { + return nil, fmt.Errorf("create besu work dir %s: %w", chainDir, mkdirErr) + } + _ = os.Chmod(chainDir, 0o777) + + configPath := filepath.Join(chainDir, "qbftConfigFile.json") + if writeErr := writeBesuOperatorConfig(configPath, spec.ChainID, periodSecs, treasury.Address()); writeErr != nil { + return nil, writeErr + } + networkFilesDir := filepath.Join(chainDir, "networkFiles") + if removeErr := os.RemoveAll(networkFilesDir); removeErr != nil { + return nil, fmt.Errorf("remove stale besu network files: %w", removeErr) + } + + namePrefix := container.NamePrefix(spec.RunID, spec.ID) + labels := container.Labels(spec.RunID) + hostUser := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + generatorName := namePrefix + "-generate" + generator, genErr := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + Started: true, + ContainerRequest: testcontainers.ContainerRequest{ + Name: generatorName, + Image: spec.Image, + Labels: labels, + WaitingFor: wait.ForExit().WithExitTimeout(besuReadyTimeout), + ConfigModifier: func(config *containertypes.Config) { + config.WorkingDir = "/work" + config.User = hostUser + }, + HostConfigModifier: func(config *containertypes.HostConfig) { + config.Mounts = append(config.Mounts, mount.Mount{ + Type: mount.TypeBind, + Source: chainDir, + Target: "/work", + }) + }, + Cmd: []string{ + "operator", "generate-blockchain-config", + "--config-file=qbftConfigFile.json", + "--to=networkFiles", + "--private-key-file-name=key", + }, + }, + }) + defer func() { + if generator != nil { + cleanupCtx, cancel := context.WithTimeout(context.Background(), besuStopTimeout) + defer cancel() + if cleanupErr := generator.Terminate(cleanupCtx); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("remove besu config generator: %w", cleanupErr)) + } + } + }() + // Besu may exit non-zero after writing complete artifacts. + dataDir, err := prepareBesuNodeDir(chainDir) + if err != nil { + if genErr != nil { + return nil, errors.Join( + fmt.Errorf("generate besu QBFT config: %w", genErr), + fmt.Errorf("validate generated besu QBFT artifacts: %w", err), + ) + } + return nil, fmt.Errorf("validate generated besu QBFT artifacts: %w", err) + } + if generator != nil { + cleanupCtx, cancel := context.WithTimeout(ctx, besuStopTimeout) + cleanupErr := generator.Terminate(cleanupCtx) + cancel() + if cleanupErr != nil { + return nil, fmt.Errorf("remove besu config generator: %w", cleanupErr) + } + generator = nil + } + + bc := &Chain{ + logID: spec.ID, + treasury: treasury, + wait: evm.TransactionWait{ + Timeout: 20 * time.Duration(periodSecs) * time.Second, + PollInterval: 250 * time.Millisecond, + }, + } + defer func() { + if result == nil { + if stopErr := bc.Stop(); stopErr != nil { + err = errors.Join(err, fmt.Errorf("clean up failed besu start: %w", stopErr)) + } + } + }() + + request := testcontainers.ContainerRequest{ + Name: namePrefix, + Image: spec.Image, + Labels: labels, + ExposedPorts: []string{"8545/tcp"}, + ConfigModifier: func(config *containertypes.Config) { + config.User = hostUser + }, + HostConfigModifier: func(config *containertypes.HostConfig) { + container.BindPortsToLoopback(config, "8545/tcp") + config.Mounts = append( + config.Mounts, + mount.Mount{ + Type: mount.TypeBind, + Source: filepath.Join(chainDir, "genesis.json"), + Target: "/config/genesis.json", + ReadOnly: true, + }, + mount.Mount{Type: mount.TypeBind, Source: dataDir, Target: "/var/lib/besu"}, + ) + }, + Cmd: []string{ + "--data-path=/var/lib/besu", + "--genesis-file=/config/genesis.json", + "--min-gas-price=0", + "--rpc-http-enabled", + "--rpc-http-host=0.0.0.0", + "--rpc-http-api=ETH,NET,WEB3", + "--host-allowlist=*", + }, + } + bc.container, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: request, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start besu container %s: %w", namePrefix, err) + } + host, err := bc.container.Host(ctx) + if err != nil { + return nil, fmt.Errorf("resolve besu host: %w", err) + } + port, err := bc.container.MappedPort(ctx, "8545/tcp") + if err != nil { + return nil, fmt.Errorf("resolve besu RPC port: %w", err) + } + bc.Identity = evm.NewIdentity(spec.ID, fmt.Sprintf("http://%s:%s", host, port.Port())) + + client, err := ethclient.DialContext(ctx, bc.RPCURL()) + if err != nil { + return nil, fmt.Errorf("dial besu chain %s: %w", spec.ID, err) + } + if readyErr := waitBesuReady(ctx, client, spec); readyErr != nil { + client.Close() + return nil, besuStartupError(bc, readyErr) + } + ec, err := evm.NewConnectedClient(ctx, client) + if err != nil { + client.Close() + return nil, besuStartupError(bc, fmt.Errorf("connect besu chain %s: %w", spec.ID, err)) + } + + bc.EVMClient = ec + return bc, nil +} + +// EnsureEOABalance transfers only the shortfall from the Chain's private +// treasury, then verifies the requested minimum on-chain. +func (bc *Chain) EnsureEOABalance(ctx context.Context, address common.Address, minimum *big.Int) error { + if minimum == nil || minimum.Sign() < 0 { + return fmt.Errorf("besu ensure EOA balance: minimum must be non-nil and non-negative") + } + if err := bc.RequireEOA(ctx, address); err != nil { + return fmt.Errorf("besu ensure EOA balance: %w", err) + } + + bc.fundingMu.Lock() + defer bc.fundingMu.Unlock() + + current, err := bc.Client().BalanceAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("besu query balance for %s: %w", address, err) + } + if current.Cmp(minimum) >= 0 { + return nil + } + if address == bc.treasury.Address() { + return fmt.Errorf( + "besu treasury balance %s is below requested minimum %s", + current, + minimum, + ) + } + + shortfall := new(big.Int).Sub(minimum, current) + if _, sendErr := bc.BroadcastTx(ctx, bc.wait, bc.treasury, &address, nil, shortfall); sendErr != nil { + return fmt.Errorf("besu fund %s with %s wei: %w", address, shortfall, sendErr) + } + got, err := bc.Client().BalanceAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("besu verify balance for %s: %w", address, err) + } + if got.Cmp(minimum) < 0 { + return fmt.Errorf("besu verify balance for %s: got %s, want at least %s", address, got, minimum) + } + return nil +} + +func (bc *Chain) CollectLogs(ctx context.Context) map[string]string { + ctx, cancel := context.WithTimeout(ctx, besuLogTimeout) + defer cancel() + + if bc.container == nil { + return nil + } + reader, err := bc.container.Logs(ctx) + if err != nil { + return nil + } + defer reader.Close() //nolint:errcheck + data, err := io.ReadAll(reader) + if err != nil || len(data) == 0 { + return nil + } + return map[string]string{bc.logID: string(data)} +} + +func besuStartupError(bc *Chain, err error) error { + logs := besuStartupLogs(bc) + if logs == "" { + return err + } + return fmt.Errorf("%w\npartial besu logs:\n%s", err, logs) +} + +func besuStartupLogs(bc *Chain) string { + data := bc.CollectLogs(context.Background())[bc.logID] + if data == "" { + return "" + } + return fmt.Sprintf("--- %s ---\n%s", bc.logID, evm.Tail(data, besuStartupLogTailBytes)) +} + +func (bc *Chain) Stop() error { + bc.mu.Lock() + if bc.stopped { + bc.mu.Unlock() + return nil + } + if !bc.closed && bc.EVMClient != nil { + bc.Close() + bc.closed = true + } + container := bc.container + bc.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), besuStopTimeout) + defer cancel() + + if container != nil { + if err := container.Terminate(ctx); err != nil { + return err + } + } + bc.mu.Lock() + bc.stopped = true + bc.mu.Unlock() + return nil +} + +type besuOperatorConfig struct { + Genesis besuGenesis `json:"genesis"` + Blockchain besuBlockchain `json:"blockchain"` +} + +type besuGenesis struct { + Config besuGenesisConfig `json:"config"` + Nonce string `json:"nonce"` + Timestamp string `json:"timestamp"` + GasLimit string `json:"gasLimit"` + Difficulty string `json:"difficulty"` + MixHash string `json:"mixHash"` + Coinbase string `json:"coinbase"` + Alloc map[string]besuFund `json:"alloc"` +} + +type besuGenesisConfig struct { + ChainID uint64 `json:"chainId"` + BerlinBlock uint64 `json:"berlinBlock"` + LondonBlock uint64 `json:"londonBlock"` + ShanghaiTime uint64 `json:"shanghaiTime"` + CancunTime uint64 `json:"cancunTime"` + ZeroBaseFee bool `json:"zeroBaseFee"` + ContractSizeLimit int `json:"contractSizeLimit"` + QBFT besuQBFTConfig `json:"qbft"` +} + +type besuQBFTConfig struct { + BlockPeriodSeconds int `json:"blockperiodseconds"` + EpochLength int `json:"epochlength"` + RequestTimeoutSeconds int `json:"requesttimeoutseconds"` +} + +type besuFund struct { + Balance string `json:"balance"` +} + +type besuBlockchain struct { + Nodes besuNodes `json:"nodes"` +} + +type besuNodes struct { + Generate bool `json:"generate"` + Count int `json:"count"` +} + +func writeBesuOperatorConfig(path string, chainID uint64, blockPeriodSecs int, treasury common.Address) error { + data, err := json.MarshalIndent(newBesuOperatorConfig(chainID, blockPeriodSecs, treasury), "", " ") + if err != nil { + return fmt.Errorf("marshal besu QBFT config: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write besu QBFT config %s: %w", path, err) + } + return nil +} + +func newBesuOperatorConfig(chainID uint64, blockPeriodSecs int, treasury common.Address) besuOperatorConfig { + alloc := map[string]besuFund{ + besuAllocKey(treasury): {Balance: besuTreasuryBalanceHex}, + } + return besuOperatorConfig{ + Genesis: besuGenesis{ + Config: besuGenesisConfig{ + ChainID: chainID, + BerlinBlock: 0, + LondonBlock: 0, + ShanghaiTime: 0, + CancunTime: 0, + ZeroBaseFee: true, + ContractSizeLimit: 2147483647, + QBFT: besuQBFTConfig{ + BlockPeriodSeconds: blockPeriodSecs, + EpochLength: besuEpochLength, + // QBFT requires the round timeout to exceed one block period. + RequestTimeoutSeconds: 2 * blockPeriodSecs, + }, + }, + Nonce: "0x0", + Timestamp: "0x58ee40ba", + GasLimit: "0x1fffffffffffff", + Difficulty: "0x1", + MixHash: "0x63746963616c2062797a616e74696e65206661756c7420746f6c6572616e6365", + Coinbase: "0x0000000000000000000000000000000000000000", + Alloc: alloc, + }, + Blockchain: besuBlockchain{ + Nodes: besuNodes{Generate: true, Count: 1}, + }, + } +} + +func besuAllocKey(addr common.Address) string { + return strings.TrimPrefix(strings.ToLower(addr.Hex()), "0x") +} + +func prepareBesuNodeDir(chainDir string) (string, error) { + genesisSrc := filepath.Join(chainDir, "networkFiles", "genesis.json") + genesisDst := filepath.Join(chainDir, "genesis.json") + if err := copyFile(genesisSrc, genesisDst, 0o644); err != nil { + return "", fmt.Errorf("copy besu genesis: %w", err) + } + + keyRoot := filepath.Join(chainDir, "networkFiles", "keys") + entries, err := os.ReadDir(keyRoot) + if err != nil { + return "", fmt.Errorf("read besu validator keys: %w", err) + } + if len(entries) == 0 || !entries[0].IsDir() { + return "", fmt.Errorf("besu generated no validator key directory under %s", keyRoot) + } + + dataDir := filepath.Join(chainDir, "node", "data") + if err := os.MkdirAll(dataDir, 0o777); err != nil { + return "", fmt.Errorf("create besu node data dir: %w", err) + } + _ = os.Chmod(dataDir, 0o777) + // The bind-mounted key must be readable by Besu's uid 1000 on native Linux. + keySrc := filepath.Join(keyRoot, entries[0].Name(), "key") + if err := copyFile(keySrc, filepath.Join(dataDir, "key"), 0o644); err != nil { + return "", fmt.Errorf("copy validator key: %w", err) + } + return dataDir, nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, mode) +} + +func waitBesuReady(ctx context.Context, client *ethclient.Client, spec Spec) error { + ctx, cancel := context.WithTimeout(ctx, besuReadyTimeout) + defer cancel() + + var startHeight uint64 + var sawHeight bool + var lastErr error + err := poll.Until(ctx, 250*time.Millisecond, func(ctx context.Context) (bool, error) { + got, err := client.ChainID(ctx) + if err != nil { + lastErr = err + return false, nil + } + if got.Uint64() != spec.ChainID { + return false, fmt.Errorf("besu chain %s reports chain id %d, want %d", spec.ID, got.Uint64(), spec.ChainID) + } + height, err := client.BlockNumber(ctx) + if err != nil { + lastErr = err + return false, nil + } + if !sawHeight { + startHeight = height + sawHeight = true + return false, nil + } + return height > startHeight, nil + }) + if err != nil { + return fmt.Errorf("besu chain %s readiness: %w (last probe: %w)", spec.ID, err, lastErr) + } + return nil +} diff --git a/e2e/internal/harness/chain/evm/besu/besu_test.go b/e2e/internal/harness/chain/evm/besu/besu_test.go new file mode 100644 index 000000000..c59bf3e3e --- /dev/null +++ b/e2e/internal/harness/chain/evm/besu/besu_test.go @@ -0,0 +1,70 @@ +package besu + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const expectedTreasuryBalanceHex = "0xc9f2c9cd04674edea40000000" + +func TestBesuOperatorConfigEnvironmentInvariants(t *testing.T) { + const chainID = 32337 + const blockPeriodSecs = 2 + + treasury := common.HexToAddress("0x1000000000000000000000000000000000000001") + cfg := newBesuOperatorConfig(chainID, blockPeriodSecs, treasury) + + require.Equal(t, uint64(chainID), cfg.Genesis.Config.ChainID) + require.Equal(t, 1, cfg.Blockchain.Nodes.Count) + require.Equal(t, blockPeriodSecs, cfg.Genesis.Config.QBFT.BlockPeriodSeconds) + require.Equal(t, 2*blockPeriodSecs, cfg.Genesis.Config.QBFT.RequestTimeoutSeconds) + require.True(t, cfg.Genesis.Config.ZeroBaseFee) + require.Equal(t, uint64(0), cfg.Genesis.Config.LondonBlock) + require.Equal(t, uint64(0), cfg.Genesis.Config.ShanghaiTime) + require.Equal(t, uint64(0), cfg.Genesis.Config.CancunTime) + + require.Equal(t, map[string]besuFund{ + besuAllocKey(treasury): {Balance: expectedTreasuryBalanceHex}, + }, cfg.Genesis.Alloc) +} + +func TestPrepareBesuNodeDir(t *testing.T) { + t.Run("stages complete generated output", func(t *testing.T) { + chainDir := t.TempDir() + writeGeneratedBesuFile(t, chainDir, "genesis.json", "genesis") + writeGeneratedBesuFile(t, chainDir, filepath.Join("keys", "validator-1", "key"), "key") + + dataDir, err := prepareBesuNodeDir(chainDir) + require.NoError(t, err) + require.FileExists(t, filepath.Join(chainDir, "genesis.json")) + require.FileExists(t, filepath.Join(dataDir, "key")) + }) + + t.Run("rejects missing genesis", func(t *testing.T) { + chainDir := t.TempDir() + writeGeneratedBesuFile(t, chainDir, filepath.Join("keys", "validator-1", "key"), "key") + + _, err := prepareBesuNodeDir(chainDir) + require.ErrorContains(t, err, "copy besu genesis") + }) + + t.Run("rejects missing validator key", func(t *testing.T) { + chainDir := t.TempDir() + writeGeneratedBesuFile(t, chainDir, "genesis.json", "genesis") + require.NoError(t, os.MkdirAll(filepath.Join(chainDir, "networkFiles", "keys", "validator-1"), 0o755)) + + _, err := prepareBesuNodeDir(chainDir) + require.ErrorContains(t, err, "copy validator key") + }) +} + +func writeGeneratedBesuFile(t *testing.T, chainDir, relativePath, contents string) { + t.Helper() + path := filepath.Join(chainDir, "networkFiles", relativePath) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) +} diff --git a/e2e/internal/harness/chain/evm/client.go b/e2e/internal/harness/chain/evm/client.go new file mode 100644 index 000000000..d3556beb2 --- /dev/null +++ b/e2e/internal/harness/chain/evm/client.go @@ -0,0 +1,313 @@ +package evm + +import ( + "context" + "errors" + "fmt" + "math/big" + "os" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/poll" + + ethereum "github.com/ethereum/go-ethereum" +) + +const ( + defaultGasLimit = 21_000 + gasPaddingPercent = 20 + gasPaddingFixed = 10_000 + // Anvil may suggest no priority fee on an empty mempool. + fallbackTipCapWei = 1_000_000_000 +) + +// TransactionWait bounds transaction submission and observation. Both fields +// must be positive; PollInterval controls pending-pool and receipt probes. +type TransactionWait struct { + Timeout time.Duration + PollInterval time.Duration +} + +func (w TransactionWait) context(ctx context.Context) (context.Context, context.CancelFunc, error) { + switch { + case w.Timeout <= 0: + return nil, nil, fmt.Errorf("evm transaction wait timeout must be greater than zero") + case w.PollInterval <= 0: + return nil, nil, fmt.Errorf("evm transaction wait poll interval must be greater than zero") + default: + waitCtx, cancel := context.WithTimeout(ctx, w.Timeout) + return waitCtx, cancel, nil + } +} + +type EVMClient struct { + client *ethclient.Client + chainID *big.Int + + // Local sends must be serialized across nonce reads and submission, + // regardless of which account pays for the transaction. + txGate chan struct{} +} + +// The caller remains responsible for readiness. +func NewConnectedClient(ctx context.Context, c *ethclient.Client) (*EVMClient, error) { + id, err := c.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("query chain id: %w", err) + } + return &EVMClient{client: c, chainID: id, txGate: make(chan struct{}, 1)}, nil +} + +// It closes c on failure. +func NewVerifiedClient( + ctx context.Context, + c *ethclient.Client, + wantChainID uint64, + label string, +) (*EVMClient, error) { + ec, err := NewConnectedClient(ctx, c) + if err != nil { + c.Close() + return nil, fmt.Errorf("connect %s: %w", label, err) + } + if got := ec.ChainID().Uint64(); got != wantChainID { + ec.Close() + return nil, fmt.Errorf("%s reports chain id %d, want %d", label, got, wantChainID) + } + return ec, nil +} + +func (e *EVMClient) Client() *ethclient.Client { return e.client } + +func (e *EVMClient) WithEVMClient(use func(*EVMClient) error) error { return use(e) } + +func (e *EVMClient) ChainID() *big.Int { return new(big.Int).Set(e.chainID) } + +func (e *EVMClient) RPCClient() *rpc.Client { return e.client.Client() } + +func (e *EVMClient) Close() { e.client.Close() } + +func (e *EVMClient) Height(ctx context.Context) (uint64, error) { + return e.client.BlockNumber(ctx) +} + +func Tail(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + return s[len(s)-maxBytes:] +} + +func (e *EVMClient) Logs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) { + return e.client.FilterLogs(ctx, q) +} + +// RequireEOA rejects addresses that cannot be funded consistently through a +// plain value transfer across managed EVM providers. +func (e *EVMClient) RequireEOA(ctx context.Context, address common.Address) error { + if address == (common.Address{}) { + return fmt.Errorf("EOA address is zero") + } + code, err := e.client.CodeAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("query code at %s: %w", address, err) + } + if len(code) != 0 { + return fmt.Errorf("address %s has contract code and is not an EOA", address) + } + return nil +} + +// It returns only after mining and treats reverts as errors. +func (e *EVMClient) BroadcastTx( + ctx context.Context, + wait TransactionWait, + from Account, + to *common.Address, + data []byte, + value *big.Int, +) (*types.Receipt, error) { + waitCtx, cancel, err := wait.context(ctx) + if err != nil { + return nil, err + } + defer cancel() + + signed, err := e.signAndSend(waitCtx, from, to, data, value) + if err != nil { + return nil, normalizeWaitError(waitCtx, err) + } + receipt, err := e.waitForReceipt(waitCtx, signed.Hash(), wait) + if err != nil { + return nil, err + } + if receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf("tx %s reverted on-chain (status %d)", signed.Hash(), receipt.Status) + } + return receipt, nil +} + +func (e *EVMClient) signAndSend( + ctx context.Context, + from Account, + to *common.Address, + data []byte, + value *big.Int, +) (*types.Transaction, error) { + if err := e.acquireTx(ctx); err != nil { + return nil, err + } + defer func() { <-e.txGate }() + + signed, err := e.buildSignedTx(ctx, from, to, data, value) + if err != nil { + return nil, err + } + if err := e.client.SendTransaction(ctx, signed); err != nil { + return nil, fmt.Errorf("send tx: %w", err) + } + return signed, nil +} + +func (e *EVMClient) acquireTx(ctx context.Context) error { + select { + case e.txGate <- struct{}{}: + if err := ctx.Err(); err != nil { + <-e.txGate + return fmt.Errorf("wait for transaction submission slot: %w", err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("wait for transaction submission slot: %w", ctx.Err()) + } +} + +func (e *EVMClient) buildSignedTx( + ctx context.Context, + from Account, + to *common.Address, + data []byte, + value *big.Int, +) (*types.Transaction, error) { + nonce, err := e.client.PendingNonceAt(ctx, from.addr) + if err != nil { + return nil, fmt.Errorf("pending nonce: %w", err) + } + + val := new(big.Int) + if value != nil { + val.Set(value) + } + + est, err := e.client.EstimateGas(ctx, ethereum.CallMsg{From: from.addr, To: to, Value: val, Data: data}) + if err != nil { + return nil, fmt.Errorf("estimate gas: %w", err) + } + gas := est + est*gasPaddingPercent/100 + gasPaddingFixed + if gas < defaultGasLimit { + gas = defaultGasLimit + } + + tipCap, err := e.client.SuggestGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("suggest gas tip cap: %w", err) + } + if tipCap == nil || tipCap.Sign() <= 0 { + tipCap = big.NewInt(fallbackTipCapWei) + } + + hdr, err := e.client.HeaderByNumber(ctx, nil) + if err != nil { + return nil, fmt.Errorf("latest header: %w", err) + } + feeCap := new(big.Int).Set(tipCap) + if hdr.BaseFee != nil && hdr.BaseFee.Sign() > 0 { + feeCap.Add(new(big.Int).Mul(hdr.BaseFee, big.NewInt(2)), tipCap) + } + + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: e.chainID, + Nonce: nonce, + To: to, + Value: val, + Gas: gas, + GasFeeCap: feeCap, + GasTipCap: tipCap, + Data: data, + }) + signed, err := types.SignTx(tx, types.LatestSignerForChainID(e.chainID), from.key) + if err != nil { + return nil, fmt.Errorf("sign tx: %w", err) + } + return signed, nil +} + +// WaitPendingTx waits until the node's transaction pool holds at least one +// pending transaction. It deliberately does not snapshot-and-diff the pool: the +// awaited transaction may already be pending when the wait starts. +func (e *EVMClient) WaitPendingTx(ctx context.Context, wait TransactionWait) error { + waitCtx, cancel, err := wait.context(ctx) + if err != nil { + return err + } + defer cancel() + + if err := poll.Until(waitCtx, wait.PollInterval, func(ctx context.Context) (bool, error) { + n, err := e.client.PendingTransactionCount(ctx) + if err != nil { + return false, err + } + return n > 0, nil + }); err != nil { + err = normalizeWaitError(waitCtx, err) + return fmt.Errorf("waiting for a pending tx: %w", err) + } + return nil +} + +func (e *EVMClient) waitForReceipt( + ctx context.Context, + hash common.Hash, + wait TransactionWait, +) (*types.Receipt, error) { + var receipt *types.Receipt + err := poll.Until(ctx, wait.PollInterval, func(ctx context.Context) (bool, error) { + r, err := e.client.TransactionReceipt(ctx, hash) + if err == nil && r != nil { + receipt = r + return true, nil + } + if err != nil && !errors.Is(err, ethereum.NotFound) { + return false, err + } + return false, nil + }) + if err != nil { + err = normalizeWaitError(ctx, err) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return nil, fmt.Errorf("receipt for %s not found in time: %w", hash, err) + } + return nil, fmt.Errorf("poll receipt %s: %w", hash, err) + } + return receipt, nil +} + +// go-ethereum can surface a context deadline as the underlying connection's +// os.ErrDeadlineExceeded before context.Err observes the timer firing. +func normalizeWaitError(ctx context.Context, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + if errors.Is(err, ctxErr) { + return err + } + return fmt.Errorf("%w (%w)", ctxErr, err) + } + if errors.Is(err, os.ErrDeadlineExceeded) { + return fmt.Errorf("%w (%w)", context.DeadlineExceeded, err) + } + return err +} diff --git a/e2e/internal/harness/chain/evm/client_test.go b/e2e/internal/harness/chain/evm/client_test.go new file mode 100644 index 000000000..95cf81b8c --- /dev/null +++ b/e2e/internal/harness/chain/evm/client_test.go @@ -0,0 +1,38 @@ +package evm + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRequireEOARejectsZeroBeforeRPC(t *testing.T) { + err := (&EVMClient{}).RequireEOA(t.Context(), common.Address{}) + require.ErrorContains(t, err, "EOA address is zero") +} + +func TestTransactionSerializationObservesContext(t *testing.T) { + client := &EVMClient{txGate: make(chan struct{}, 1)} + client.txGate <- struct{}{} + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + + err := client.acquireTx(ctx) + + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestNormalizeWaitErrorPreservesDeadlineAndOperation(t *testing.T) { + err := normalizeWaitError( + context.Background(), + fmt.Errorf("read pending nonce: %w", os.ErrDeadlineExceeded), + ) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.ErrorContains(t, err, "read pending nonce") +} diff --git a/e2e/internal/harness/chain/evm/container/container.go b/e2e/internal/harness/chain/evm/container/container.go new file mode 100644 index 000000000..6a6500004 --- /dev/null +++ b/e2e/internal/harness/chain/evm/container/container.go @@ -0,0 +1,53 @@ +// Package container contains metadata shared by managed EVM test containers. +package container + +import ( + "net/netip" + "regexp" + "strings" + + "github.com/moby/moby/api/types/network" + + containertypes "github.com/moby/moby/api/types/container" +) + +var nameRe = regexp.MustCompile(`[^a-z0-9_.-]+`) + +func Labels(runID string) map[string]string { + return map[string]string{ + "ibc-link-e2e": "true", + "ibc-link-run": runID, + } +} + +func NamePrefix(runID, chainID string) string { + return "ibc-link-e2e-" + safe(runID) + "-" + safe(chainID) +} + +// BindPortsToLoopback keeps development RPC endpoints off external interfaces +// while allowing Docker to choose race-free host ports. +func BindPortsToLoopback(config *containertypes.HostConfig, ports ...string) { + if config.PortBindings == nil { + config.PortBindings = network.PortMap{} + } + for _, value := range ports { + port := network.MustParsePort(value) + bindings := config.PortBindings[port] + if len(bindings) == 0 { + bindings = []network.PortBinding{{HostPort: "0"}} + } + for i := range bindings { + bindings[i].HostIP = netip.MustParseAddr("127.0.0.1") + } + config.PortBindings[port] = bindings + } +} + +func safe(value string) string { + value = nameRe.ReplaceAllString(strings.ToLower(value), "-") + value = strings.Trim(value, "-_.") + if value == "" { + return "run" + } + return value +} diff --git a/e2e/internal/harness/chain/evm/container/container_test.go b/e2e/internal/harness/chain/evm/container/container_test.go new file mode 100644 index 000000000..e3603e4a7 --- /dev/null +++ b/e2e/internal/harness/chain/evm/container/container_test.go @@ -0,0 +1,24 @@ +package container + +import ( + "net/netip" + "testing" + + "github.com/moby/moby/api/types/network" + "github.com/stretchr/testify/require" + + containertypes "github.com/moby/moby/api/types/container" +) + +func TestNamePrefixSanitizesDockerNames(t *testing.T) { + require.Equal(t, "ibc-link-e2e-run-id-chain-a", NamePrefix("Run ID", "Chain/A")) + require.Equal(t, "ibc-link-e2e-run-run", NamePrefix("!!!", "")) +} + +func TestBindExposedPortsToLoopbackPreservesDynamicPorts(t *testing.T) { + config := &containertypes.HostConfig{PortBindings: network.PortMap{}} + BindPortsToLoopback(config, "8545/tcp") + port := network.MustParsePort("8545/tcp") + require.Equal(t, netip.MustParseAddr("127.0.0.1"), config.PortBindings[port][0].HostIP) + require.Equal(t, "0", config.PortBindings[port][0].HostPort) +} diff --git a/e2e/internal/harness/chain/evm/poll/poll.go b/e2e/internal/harness/chain/evm/poll/poll.go new file mode 100644 index 000000000..47f92ab9d --- /dev/null +++ b/e2e/internal/harness/chain/evm/poll/poll.go @@ -0,0 +1,32 @@ +// Package poll provides readiness polling for EVM clients and launchers. +package poll + +import ( + "context" + "time" +) + +// Until returns the first predicate error and does not retry it. +func Until( + ctx context.Context, + interval time.Duration, + pred func(context.Context) (done bool, err error), +) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + done, err := pred(ctx) + if err != nil { + return err + } + if done { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/e2e/internal/harness/chain/evm/provider.go b/e2e/internal/harness/chain/evm/provider.go new file mode 100644 index 000000000..4627fed92 --- /dev/null +++ b/e2e/internal/harness/chain/evm/provider.go @@ -0,0 +1,25 @@ +package evm + +import "github.com/cosmos/ibc/e2e/internal/harness/chain" + +type Identity struct { + id string + rpcURL string +} + +func NewIdentity(id, rpcURL string) Identity { return Identity{id: id, rpcURL: rpcURL} } + +func (i Identity) ID() string { return i.id } +func (i Identity) RPCURL() string { return i.rpcURL } + +type clientAccessor interface { + WithEVMClient(func(*EVMClient) error) error +} + +// WithChainClient resolves the provider's current client for each operation. +func WithChainClient(c chain.Chain, use func(*EVMClient) error) (bool, error) { + if accessor, ok := c.(clientAccessor); ok { + return true, accessor.WithEVMClient(use) + } + return false, nil +} diff --git a/e2e/internal/harness/environment/app.go b/e2e/internal/harness/environment/app.go new file mode 100644 index 000000000..24334052b --- /dev/null +++ b/e2e/internal/harness/environment/app.go @@ -0,0 +1,290 @@ +package environment + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/environment/solidityibc" +) + +// AuthorityAddress resolves the checksummed EVM address of a runtime authority so +// callers can fund it or assert against it without holding its key. +func (e *Environment) AuthorityAddress(id AuthorityID) (EVMAddress, error) { + account, err := e.runtime.evmAccount(id) + if err != nil { + return "", err + } + return EVMAddress(account.Address().Hex()), nil +} + +// AuthorizePublicRelay opens an IBC Instance's ICS26 router relayer-restricted +// functions to any caller, so a non-admin Relayer signer can submit +// recvPacket/ackPacket/timeoutPacket/updateClient. authority must resolve to the +// router's AccessManager admin. +func (e *Environment) AuthorizePublicRelay(ctx context.Context, instance IBCInstanceID, authority AuthorityID) error { + resolved, err := e.IBCInstance(instance) + if err != nil { + return err + } + account, err := e.runtime.evmAccount(authority) + if err != nil { + return err + } + return e.withSolidityIBC(ctx, resolved.chain, func(setup *solidityibc.Setup) error { + return setup.AuthorizePublicRelay( + ctx, + account, + common.HexToAddress(string(resolved.accessManager)), + common.HexToAddress(string(resolved.locator)), + ) + }) +} + +// IFTRequest declares an IFT test-application deployment on one IBC Instance. +// Authority must resolve to that Instance's AccessManager admin. +type IFTRequest struct { + Instance IBCInstanceID + Authority AuthorityID + TokenName string + TokenSymbol string +} + +// IFTApp is a deployed IFT token and its ICS27 GMP app on one resolved IBC +// Instance. It hides the ABI, RPC, and event-decoding mechanics while leaving +// deployment, bridge registration, and transfer orchestration to the caller. +type IFTApp struct { + env *Environment + instance *IBCInstance + authority AuthorityID + address common.Address + gmp common.Address + sendCallConstructor common.Address +} + +// DeployIFT deploys the ICS27 GMP application and an IFT token on the Instance's +// Chain, returning a handle bound to the token. Both chains' IFTs must be +// deployed before their bridges can be registered against one another. +func (e *Environment) DeployIFT(ctx context.Context, req IFTRequest) (*IFTApp, error) { + resolved, err := e.IBCInstance(req.Instance) + if err != nil { + return nil, err + } + account, err := e.runtime.evmAccount(req.Authority) + if err != nil { + return nil, err + } + router := common.HexToAddress(string(resolved.locator)) + accessManager := common.HexToAddress(string(resolved.accessManager)) + + app := &IFTApp{env: e, instance: resolved, authority: req.Authority} + err = e.withSolidityIBC(ctx, resolved.chain, func(setup *solidityibc.Setup) error { + gmp, deployErr := setup.DeployGMPApp(ctx, account, router, accessManager) + if deployErr != nil { + return deployErr + } + ift, deployErr := setup.DeployIFT(ctx, account, account.Address(), gmp, req.TokenName, req.TokenSymbol) + if deployErr != nil { + return deployErr + } + sendCall, deployErr := setup.DeploySendCallConstructor(ctx, account) + if deployErr != nil { + return deployErr + } + app.gmp = gmp + app.address = ift + app.sendCallConstructor = sendCall + return nil + }) + if err != nil { + return nil, fmt.Errorf("environment: deploy IFT on Instance %q: %w", req.Instance, err) + } + return app, nil +} + +// Address is the IFT token's ERC1967 proxy address. +func (a *IFTApp) Address() EVMAddress { return EVMAddress(a.address.Hex()) } + +// GMPAddress is the ICS27 GMP proxy the token routes through. +func (a *IFTApp) GMPAddress() EVMAddress { return EVMAddress(a.gmp.Hex()) } + +// RegisterBridge maps the local sourceClient to the counterparty IFT so transfers +// over that client mint on the far side. +func (a *IFTApp) RegisterBridge(ctx context.Context, sourceClient IBCClientLocator, counterpartyIFT EVMAddress) error { + account, err := a.env.runtime.evmAccount(a.authority) + if err != nil { + return err + } + return a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + return setup.RegisterIFTBridge( + ctx, + account, + a.address, + string(sourceClient), + string(counterpartyIFT), + a.sendCallConstructor, + ) + }) +} + +// Mint credits holder with amount of the token using the admin authority. +func (a *IFTApp) Mint(ctx context.Context, holder EVMAddress, amount *big.Int) error { + account, err := a.env.runtime.evmAccount(a.authority) + if err != nil { + return err + } + return a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + return setup.MintIFT(ctx, account, a.address, common.HexToAddress(string(holder)), amount) + }) +} + +// IFTTransferRequest is one iftTransfer send. Sender must resolve to a funded +// account holding the token. +type IFTTransferRequest struct { + Sender AuthorityID + SourceClient IBCClientLocator + Receiver EVMAddress + Amount *big.Int + TimeoutTimestamp uint64 +} + +// IFTTransferReceipt reports the ICS26 packet sequence and source transaction of +// an iftTransfer send. +type IFTTransferReceipt struct { + Sequence uint64 + SourceTxHash string +} + +// Transfer sends amount over the source client and returns the packet sequence. +// +//nolint:dupl // Transfer and GMPCall share one orchestration shape by design. +func (a *IFTApp) Transfer(ctx context.Context, req IFTTransferRequest) (IFTTransferReceipt, error) { + account, err := a.env.runtime.evmAccount(req.Sender) + if err != nil { + return IFTTransferReceipt{}, err + } + var result solidityibc.IFTTransferResult + err = a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + var transferErr error + result, transferErr = setup.IFTTransfer( + ctx, + account, + a.address, + common.HexToAddress(string(a.instance.locator)), + string(req.SourceClient), + string(req.Receiver), + req.Amount, + req.TimeoutTimestamp, + ) + return transferErr + }) + if err != nil { + return IFTTransferReceipt{}, err + } + return IFTTransferReceipt{Sequence: result.Sequence, SourceTxHash: result.SourceTxHash.Hex()}, nil +} + +// GMPCallRequest is one ICS27 GMP sendCall issued through this app's GMP proxy. +// Sender must resolve to a funded account. Receiver is the destination target +// contract; Payload is the calldata the destination ICS27 account executes +// against Receiver. +type GMPCallRequest struct { + Sender AuthorityID + SourceClient IBCClientLocator + Receiver EVMAddress + Payload []byte + TimeoutTimestamp uint64 +} + +// GMPCallReceipt reports the ICS26 packet sequence and source transaction of a +// GMP sendCall. +type GMPCallReceipt struct { + Sequence uint64 + SourceTxHash string +} + +// GMPCall submits a GMP sendCall through this app's ICS27 GMP proxy and returns +// the packet sequence. +// +//nolint:dupl // Transfer and GMPCall share one orchestration shape by design. +func (a *IFTApp) GMPCall(ctx context.Context, req GMPCallRequest) (GMPCallReceipt, error) { + account, err := a.env.runtime.evmAccount(req.Sender) + if err != nil { + return GMPCallReceipt{}, err + } + var result solidityibc.GMPSendResult + err = a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + var sendErr error + result, sendErr = setup.GMPSendCall( + ctx, + account, + a.gmp, + common.HexToAddress(string(a.instance.locator)), + string(req.SourceClient), + string(req.Receiver), + req.Payload, + req.TimeoutTimestamp, + ) + return sendErr + }) + if err != nil { + return GMPCallReceipt{}, err + } + return GMPCallReceipt{Sequence: result.Sequence, SourceTxHash: result.SourceTxHash.Hex()}, nil +} + +// ICS27Account returns the deterministic ICS27 account address on this app's +// chain for a GMP packet arriving over destinationClient from sender. Callers +// pre-fund this account so a delivered GMP call can act through it. +func (a *IFTApp) ICS27Account(ctx context.Context, destinationClient string, sender EVMAddress) (EVMAddress, error) { + var address common.Address + err := a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + var accErr error + address, accErr = setup.GMPAccountAddress(ctx, a.gmp, destinationClient, string(sender), nil) + return accErr + }) + if err != nil { + return "", err + } + return EVMAddress(address.Hex()), nil +} + +// TransferCalldata packs the ERC20 transfer(recipient, amount) calldata for any +// IFT token, for use as a GMP call payload. It depends only on the standard IFT +// ABI, so it is a package-level encoder rather than a per-app method. +func TransferCalldata(recipient EVMAddress, amount *big.Int) ([]byte, error) { + return solidityibc.IFTTransferCalldata(common.HexToAddress(string(recipient)), amount) +} + +// BalanceOf reads the token balance of account. +func (a *IFTApp) BalanceOf(ctx context.Context, account EVMAddress) (*big.Int, error) { + var balance *big.Int + err := a.env.withSolidityIBC(ctx, a.instance.chain, func(setup *solidityibc.Setup) error { + var readErr error + balance, readErr = setup.IFTBalanceOf(ctx, a.address, common.HexToAddress(string(account))) + return readErr + }) + return balance, err +} + +// withSolidityIBC runs fn with a Solidity IBC setup bound to the Chain's current +// EVM client, holding the Environment lease for the duration so a Close cannot +// race the operation. +func (e *Environment) withSolidityIBC(ctx context.Context, chain *Chain, fn func(*solidityibc.Setup) error) error { + return chain.use(func() error { + ok, err := evm.WithChainClient(chain.impl, func(client *evm.EVMClient) error { + setup, setupErr := solidityibc.NewSetup(ctx, client.Client()) + if setupErr != nil { + return setupErr + } + return fn(setup) + }) + if !ok { + return fmt.Errorf("environment: Chain %q has no EVM client", chain.id) + } + return err + }) +} diff --git a/e2e/internal/harness/environment/assessment.go b/e2e/internal/harness/environment/assessment.go new file mode 100644 index 000000000..4e480ee85 --- /dev/null +++ b/e2e/internal/harness/environment/assessment.go @@ -0,0 +1,152 @@ +package environment + +import ( + "errors" + "fmt" + "strings" +) + +var ErrInvalidRequirement = errors.New("environment: invalid requirement") + +// Requirements names workflow-visible capabilities on stable authored +// resources. It is separate from Spec because these are properties a caller +// needs from a selected Environment, not properties the caller may grant to it. +type Requirements struct { + MiningControl []ChainID + NodeLifecycle []ChainID +} + +// Assessment reports declaration-level capability gaps. It does not promise +// that external executables, Docker, credentials, or endpoints will work when +// Start performs acquisition. +type Assessment struct { + missing Requirements +} + +func (a Assessment) Feasible() bool { + return len(a.missing.MiningControl) == 0 && len(a.missing.NodeLifecycle) == 0 +} + +func (a Assessment) Missing() Requirements { + return cloneRequirements(a.missing) +} + +func (a Assessment) String() string { + if a.Feasible() { + return "feasible" + } + parts := make([]string, 0, len(a.missing.MiningControl)+len(a.missing.NodeLifecycle)) + for _, id := range a.missing.MiningControl { + parts = append(parts, fmt.Sprintf("Chain %q has no mining control", id)) + } + for _, id := range a.missing.NodeLifecycle { + parts = append(parts, fmt.Sprintf("Chain %q has no node lifecycle control", id)) + } + return strings.Join(parts, "; ") +} + +// Assess validates the complete selected Environment and classifies requirements +// without filesystem, process, Docker, RPC, or network work. Invalid Specs, +// runtime bindings, and requirement targets are errors; unavailable +// capabilities are returned as a non-feasible Assessment. +func Assess(spec Spec, runtime Runtime, requirements Requirements) (Assessment, error) { + spec = spec.snapshot() + runtime = runtime.snapshot() + if err := spec.validate(); err != nil { + return Assessment{}, err + } + if err := validateRuntime(spec, runtime); err != nil { + return Assessment{}, err + } + + chains := make(map[ChainID]chainCapabilities, len(spec.Chains)) + for _, declaration := range spec.Chains { + chains[declaration.chainID()] = deriveChainCapabilities(declaration) + } + + var assessment Assessment + missing, err := assessChainRequirement( + "MiningControl", + requirements.MiningControl, + chains, + func(capabilities chainCapabilities) bool { return capabilities.miningControl }, + ) + if err != nil { + return Assessment{}, err + } + assessment.missing.MiningControl = missing + + missing, err = assessChainRequirement( + "NodeLifecycle", + requirements.NodeLifecycle, + chains, + func(capabilities chainCapabilities) bool { return capabilities.nodeLifecycle }, + ) + if err != nil { + return Assessment{}, err + } + assessment.missing.NodeLifecycle = missing + return assessment, nil +} + +type chainCapabilities struct { + miningControl bool + nodeLifecycle bool +} + +// deriveChainCapabilities is the single declaration-level source used by both +// assessment and realization. Runtime authority can be added here when a +// concrete adapter proves that it changes a capability guarantee. +func deriveChainCapabilities(declaration ChainSpec) chainCapabilities { + switch chain := declaration.(type) { + case ManagedAnvil: + return chainCapabilities{ + miningControl: chain.BlockInterval == 0, + nodeLifecycle: true, + } + case ManagedBesu, AttachedEVM: + return chainCapabilities{} + default: + return chainCapabilities{} + } +} + +func assessChainRequirement( + field string, + ids []ChainID, + chains map[ChainID]chainCapabilities, + provides func(chainCapabilities) bool, +) ([]ChainID, error) { + missing := make([]ChainID, 0) + seen := make(map[ChainID]struct{}, len(ids)) + for i, id := range ids { + if id == "" { + return nil, fmt.Errorf("%w: %s[%d] has an empty Chain id", ErrInvalidRequirement, field, i) + } + capabilities, ok := chains[id] + if !ok { + return nil, fmt.Errorf( + "%w: %s[%d] references unknown Chain %q", + ErrInvalidRequirement, + field, + i, + id, + ) + } + if _, duplicate := seen[id]; duplicate { + continue + } + seen[id] = struct{}{} + if !provides(capabilities) { + missing = append(missing, id) + } + } + return missing, nil +} + +func cloneRequirements(in Requirements) Requirements { + return Requirements{ + MiningControl: append([]ChainID(nil), in.MiningControl...), + NodeLifecycle: append([]ChainID(nil), in.NodeLifecycle...), + } +} diff --git a/e2e/internal/harness/environment/assessment_test.go b/e2e/internal/harness/environment/assessment_test.go new file mode 100644 index 000000000..52b7e62e6 --- /dev/null +++ b/e2e/internal/harness/environment/assessment_test.go @@ -0,0 +1,82 @@ +package environment + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAssessDerivesChainCapabilities(t *testing.T) { + spec := Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "instant", EVMChainID: 31337}, + ManagedAnvil{ID: "interval", EVMChainID: 31338, BlockInterval: 2 * time.Second}, + ManagedBesu{ID: "besu", EVMChainID: 32337}, + AttachedEVM{ID: "attached", EVMChainID: 33337, Endpoint: "attached-rpc", Timing: testTiming()}, + }} + runtime := Runtime{ + Endpoints: map[EndpointBindingID]EndpointBinding{ + "attached-rpc": {RPCURL: "http://attached.invalid"}, + }, + } + + assessment, err := Assess(spec, runtime, Requirements{ + MiningControl: []ChainID{"instant", "interval", "besu", "attached", "besu"}, + NodeLifecycle: []ChainID{"instant", "interval", "besu", "attached", "attached"}, + }) + require.NoError(t, err) + require.False(t, assessment.Feasible()) + require.Equal(t, Requirements{ + MiningControl: []ChainID{"interval", "besu", "attached"}, + NodeLifecycle: []ChainID{"besu", "attached"}, + }, assessment.Missing()) + require.Equal( + t, + `Chain "interval" has no mining control; Chain "besu" has no mining control; Chain "attached" has no mining control; Chain "besu" has no node lifecycle control; Chain "attached" has no node lifecycle control`, + assessment.String(), + ) +} + +func TestAssessFeasible(t *testing.T) { + requirements := Requirements{ + MiningControl: []ChainID{"anvil"}, + NodeLifecycle: []ChainID{"anvil"}, + } + assessment, err := Assess(Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "anvil", EVMChainID: 31337}, + }}, Runtime{}, requirements) + require.NoError(t, err) + require.True(t, assessment.Feasible()) + require.Equal(t, "feasible", assessment.String()) +} + +func TestAssessRejectsInvalidRequirementTargets(t *testing.T) { + spec := Spec{Chains: []ChainSpec{ManagedAnvil{ID: "anvil", EVMChainID: 31337}}} + + _, err := Assess(spec, Runtime{}, Requirements{MiningControl: []ChainID{""}}) + require.ErrorIs(t, err, ErrInvalidRequirement) + require.ErrorContains(t, err, "MiningControl[0] has an empty Chain id") + + _, err = Assess(spec, Runtime{}, Requirements{NodeLifecycle: []ChainID{"missing"}}) + require.ErrorIs(t, err, ErrInvalidRequirement) + require.ErrorContains(t, err, `NodeLifecycle[0] references unknown Chain "missing"`) +} + +func TestAssessReturnsInvalidSelectionErrorsInsteadOfCapabilityGaps(t *testing.T) { + attached := Spec{Chains: []ChainSpec{AttachedEVM{ + ID: "attached", EVMChainID: 31337, Endpoint: "rpc", Timing: testTiming(), + }}} + _, err := Assess(attached, Runtime{}, Requirements{MiningControl: []ChainID{"attached"}}) + require.ErrorContains(t, err, `no runtime endpoint binding for "rpc"`) +} + +func TestAssessmentMissingReturnsDefensiveCopies(t *testing.T) { + assessment, err := Assess(Spec{Chains: []ChainSpec{ + ManagedBesu{ID: "besu", EVMChainID: 32337}, + }}, Runtime{}, Requirements{MiningControl: []ChainID{"besu"}}) + require.NoError(t, err) + + missing := assessment.Missing() + missing.MiningControl[0] = "mutated" + require.Equal(t, []ChainID{"besu"}, assessment.Missing().MiningControl) +} diff --git a/e2e/internal/harness/environment/chain.go b/e2e/internal/harness/environment/chain.go new file mode 100644 index 000000000..911ec573c --- /dev/null +++ b/e2e/internal/harness/environment/chain.go @@ -0,0 +1,324 @@ +package environment + +import ( + "context" + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + chainimpl "github.com/cosmos/ibc/e2e/internal/harness/chain" + chainevm "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + ethereum "github.com/ethereum/go-ethereum" +) + +var ( + ErrCapabilityUnavailable = errors.New("environment: capability unavailable") + ErrEnvironmentClosed = errors.New("environment: resolved handles cannot be used after Environment.Close") +) + +const capabilityCleanupTimeout = 15 * time.Second + +// Chain is the resolved workflow-facing view of a Chain declaration. Adapter +// values and control interfaces stay private so attachment cannot accidentally +// inherit controls from the implementation used to connect to it. +type Chain struct { + id ChainID + evmChainID uint64 + rpcURL string + timing Timing + impl chainimpl.Chain + lease *environmentLease + mining *Mining + node *NodeLifecycle + funding *Funding +} + +func (c *Chain) bindLease(lease *environmentLease) { + c.lease = lease + if c.mining != nil { + c.mining.chain = c + } + if c.node != nil { + c.node.chain = c + } + if c.funding != nil { + c.funding.chain = c + } +} + +func (c *Chain) use(use func() error) error { + var err error + if c.lease == nil { + err = use() + } else { + err = c.lease.use(use) + } + if errors.Is(err, ErrEnvironmentClosed) { + err = fmt.Errorf("%w: Chain %q", err, c.id) + } + return err +} + +func (c *Chain) ID() ChainID { return c.id } +func (c *Chain) EVMChainID() uint64 { return c.evmChainID } +func (c *Chain) RPCURL() string { return c.rpcURL } +func (c *Chain) Timing() Timing { return c.timing } + +func (c *Chain) Height(ctx context.Context) (uint64, error) { + var height uint64 + err := c.use(func() error { + var err error + height, err = c.impl.Height(ctx) + return err + }) + return height, err +} + +// EVM returns non-owning EVM operations. The Environment keeps exclusive +// control of the underlying client lifecycle. +func (c *Chain) EVM() (*EVM, error) { + err := c.use(func() error { + ok, err := chainevm.WithChainClient(c.impl, func(*chainevm.EVMClient) error { return nil }) + if !ok { + return fmt.Errorf("%w: Chain %q has no EVM access", ErrCapabilityUnavailable, c.id) + } + return err + }) + if err != nil { + return nil, err + } + return &EVM{chain: c}, nil +} + +// EVM exposes operations on an EVM Chain without exposing the owned client's +// Close method or its raw RPC client. +type EVM struct { + chain *Chain +} + +func (e *EVM) WaitPendingTx(ctx context.Context) error { + return e.use(func(client *chainevm.EVMClient) error { + return client.WaitPendingTx(ctx, e.transactionWait()) + }) +} + +func (e *EVM) BroadcastTx( + ctx context.Context, + from chainevm.Account, + to *common.Address, + data []byte, + value *big.Int, +) (*types.Receipt, error) { + var receipt *types.Receipt + err := e.use(func(client *chainevm.EVMClient) error { + var err error + receipt, err = client.BroadcastTx(ctx, e.transactionWait(), from, to, data, value) + return err + }) + return receipt, err +} + +func (e *EVM) transactionWait() chainevm.TransactionWait { + return chainevm.TransactionWait{ + Timeout: e.chain.timing.CompletionBudget, + PollInterval: e.chain.timing.PollInterval, + } +} + +func (e *EVM) Logs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { + var logs []types.Log + err := e.use(func(client *chainevm.EVMClient) error { + var err error + logs, err = client.Logs(ctx, query) + return err + }) + return logs, err +} + +func (e *EVM) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + var header *types.Header + err := e.use(func(client *chainevm.EVMClient) error { + var err error + header, err = client.Client().HeaderByNumber(ctx, number) + return err + }) + return header, err +} + +func (e *EVM) CodeAt(ctx context.Context, address common.Address, number *big.Int) ([]byte, error) { + var code []byte + err := e.use(func(client *chainevm.EVMClient) error { + var err error + code, err = client.Client().CodeAt(ctx, address, number) + return err + }) + return code, err +} + +func (e *EVM) BalanceAt(ctx context.Context, address common.Address, number *big.Int) (*big.Int, error) { + var balance *big.Int + err := e.use(func(client *chainevm.EVMClient) error { + var err error + balance, err = client.Client().BalanceAt(ctx, address, number) + return err + }) + return balance, err +} + +// UseContractCaller lends the active EVM client as a read-only generated +// contract backend. The client remains owned by the environment. +func (e *EVM) UseContractCaller(use func(bind.ContractCaller) error) error { + return e.use(func(client *chainevm.EVMClient) error { + return use(client.Client()) + }) +} + +func (e *EVM) use(use func(*chainevm.EVMClient) error) error { + return e.chain.use(func() error { + ok, err := chainevm.WithChainClient(e.chain.impl, use) + if !ok { + return fmt.Errorf("%w: Chain %q has no EVM access", ErrCapabilityUnavailable, e.chain.id) + } + return err + }) +} + +// Mining returns explicit mining control only when the selected declaration +// and adapter guarantee it. Attached Chains never receive it from connectivity +// alone, and interval-mining Anvil does not expose manual mining control. +func (c *Chain) Mining() (*Mining, error) { + err := c.use(func() error { + if c.mining == nil { + return fmt.Errorf("%w: Chain %q has no mining control", ErrCapabilityUnavailable, c.id) + } + return nil + }) + if err != nil { + return nil, err + } + return c.mining, nil +} + +// NodeLifecycle returns explicit node fault control only for environment-owned +// adapters that can safely stop and restart their node. +func (c *Chain) NodeLifecycle() (*NodeLifecycle, error) { + err := c.use(func() error { + if c.node == nil { + return fmt.Errorf("%w: Chain %q has no node lifecycle control", ErrCapabilityUnavailable, c.id) + } + return nil + }) + if err != nil { + return nil, err + } + return c.node, nil +} + +// Funding returns native-token funding only for managed Chains whose adapter +// can guarantee and verify a minimum externally owned account balance. +// Attached Chains leave funding as an external precondition. +func (c *Chain) Funding() (*Funding, error) { + err := c.use(func() error { + if c.funding == nil { + return fmt.Errorf("%w: Chain %q has no funding control", ErrCapabilityUnavailable, c.id) + } + return nil + }) + if err != nil { + return nil, err + } + return c.funding, nil +} + +type Funding struct { + controller chainimpl.EOAFunder + chain *Chain +} + +func (f *Funding) EnsureEOABalance(ctx context.Context, address common.Address, minimum *big.Int) error { + return f.chain.use(func() error { + return f.controller.EnsureEOABalance(ctx, address, minimum) + }) +} + +type Mining struct { + controller chainimpl.BlockController + chain *Chain +} + +func (m *Mining) Pause(ctx context.Context) error { + return m.chain.use(func() error { return m.controller.PauseMining(ctx) }) +} + +func (m *Mining) Resume(ctx context.Context) error { + return m.chain.use(func() error { return m.controller.ResumeMining(ctx) }) +} + +func (m *Mining) Mine(ctx context.Context, blocks int) error { + return m.chain.use(func() error { return m.controller.MineBlocks(ctx, blocks) }) +} + +func (m *Mining) AdvanceTime(ctx context.Context, d time.Duration) error { + return m.chain.use(func() error { return m.controller.AdvanceTime(ctx, d) }) +} + +func (m *Mining) WithPaused(ctx context.Context, fn func() error) error { + return m.chain.use(func() (err error) { + if pauseErr := m.controller.PauseMining(ctx); pauseErr != nil { + return pauseErr + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), capabilityCleanupTimeout) + defer cancel() + err = errors.Join(err, m.controller.ResumeMining(cleanupCtx)) + }() + return fn() + }) +} + +type NodeLifecycle struct { + controller chainimpl.FaultInjector + chain *Chain +} + +func (n *NodeLifecycle) Stop(ctx context.Context) error { + return n.chain.use(func() error { return n.controller.StopNode(ctx) }) +} + +func (n *NodeLifecycle) Start(ctx context.Context) error { + return n.chain.use(func() error { return n.controller.StartNode(ctx) }) +} + +func instantTiming() Timing { + return Timing{ + CompletionBudget: 60 * time.Second, + SettleWindow: 1500 * time.Millisecond, + PollInterval: 100 * time.Millisecond, + } +} + +func blockTiming(block time.Duration) Timing { + return Timing{ + BlockInterval: block, + CompletionBudget: 20 * block, + SettleWindow: 2 * block, + PollInterval: clampPoll(block / 4), + } +} + +func clampPoll(d time.Duration) time.Duration { + const minPoll, maxPoll = 50 * time.Millisecond, 250 * time.Millisecond + switch { + case d < minPoll: + return minPoll + case d > maxPoll: + return maxPoll + default: + return d + } +} diff --git a/e2e/internal/harness/environment/chain_test.go b/e2e/internal/harness/environment/chain_test.go new file mode 100644 index 000000000..b56eddf32 --- /dev/null +++ b/e2e/internal/harness/environment/chain_test.go @@ -0,0 +1,206 @@ +package environment + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + + chainimpl "github.com/cosmos/ibc/e2e/internal/harness/chain" +) + +// TestResolvedChainErrorsPassThrough proves a Chain read preserves the +// underlying adapter error as its cause, including host/query material the +// adapter chose to keep. +func TestResolvedChainErrorsPassThrough(t *testing.T) { + const endpoint = "https://api-user:secret-password@rpc.example.invalid/?token=query-secret" + cause := errors.New("transport failed") + chain := &Chain{ + id: "failing", + rpcURL: endpoint, + impl: formattingChain{ + endpoint: endpoint, + err: fmt.Errorf( + "GET https://api-user:***@rpc.example.invalid/?token=query-secret: %w", + cause, + ), + }, + } + + _, err := chain.Height(t.Context()) + require.ErrorIs(t, err, cause) + require.Contains(t, err.Error(), "api-user") + require.Contains(t, err.Error(), "query-secret") +} + +// TestResolvedCapabilityErrorsPassThrough proves a resolved capability (funding) +// preserves the underlying controller error as its cause. +func TestResolvedCapabilityErrorsPassThrough(t *testing.T) { + cause := errors.New("funding failed") + controller := &failingEOAFunder{err: fmt.Errorf( + "POST https://api-user:***@rpc.example.invalid/?token=query-secret: %w", + cause, + )} + chain := &Chain{id: "failing"} + funding := &Funding{ + controller: controller, + chain: chain, + } + + err := funding.EnsureEOABalance(t.Context(), common.Address{}, big.NewInt(1)) + require.ErrorIs(t, err, cause) + require.Contains(t, err.Error(), "api-user") + require.Contains(t, err.Error(), "query-secret") +} + +type failingEOAFunder struct { + err error +} + +func (f *failingEOAFunder) EnsureEOABalance(context.Context, common.Address, *big.Int) error { + return f.err +} + +type formattingChain struct { + endpoint string + err error +} + +func (formattingChain) ID() string { return "failing" } +func (c formattingChain) RPCURL() string { return c.endpoint } +func (c formattingChain) Height(context.Context) (uint64, error) { return 0, c.err } + +func TestFundingEnsuresMinimumEOABalanceThroughResolvedCapability(t *testing.T) { + controller := &recordingEOAFunder{} + chain := &Chain{ + id: "managed", funding: &Funding{controller: controller}, + } + chain.bindLease(&environmentLease{}) + funding, err := chain.Funding() + require.NoError(t, err) + + address := common.HexToAddress("0x1000000000000000000000000000000000000001") + minimum := big.NewInt(42) + require.NoError(t, funding.EnsureEOABalance(t.Context(), address, minimum)) + require.Equal(t, address, controller.address) + require.Equal(t, minimum, controller.minimum) +} + +func TestFundingUnavailableWithoutManagedControl(t *testing.T) { + chain := &Chain{id: "attached"} + chain.bindLease(&environmentLease{}) + funding, err := chain.Funding() + require.Nil(t, funding) + require.ErrorIs(t, err, ErrCapabilityUnavailable) +} + +func TestEVMTransactionWaitUsesChainTiming(t *testing.T) { + timing := Timing{ + CompletionBudget: 17 * time.Second, + PollInterval: 23 * time.Millisecond, + } + wait := (&EVM{chain: &Chain{timing: timing}}).transactionWait() + require.Equal(t, timing.CompletionBudget, wait.Timeout) + require.Equal(t, timing.PollInterval, wait.PollInterval) +} + +func TestProtocolAuthorityFundingSkipsAttachedChains(t *testing.T) { + authority, err := evm.AccountFromHex(testPrimaryPrivateKeyHex) + require.NoError(t, err) + chain := &Chain{id: "attached"} + chain.bindLease(&environmentLease{}) + require.NoError(t, ensureProtocolAuthorityFunded(t.Context(), chain, authority)) +} + +func TestProtocolAuthorityFundingUsesManagedCapability(t *testing.T) { + authority, err := evm.AccountFromHex(testPrimaryPrivateKeyHex) + require.NoError(t, err) + controller := &recordingEOAFunder{} + chain := &Chain{ + id: "managed", + funding: &Funding{controller: controller}, + } + chain.bindLease(&environmentLease{}) + require.NoError(t, ensureProtocolAuthorityFunded(t.Context(), chain, authority)) + require.Equal(t, authority.Address(), controller.address) + require.Equal(t, "100000000000000000000", controller.minimum.String()) +} + +type recordingEOAFunder struct { + address common.Address + minimum *big.Int +} + +func (f *recordingEOAFunder) EnsureEOABalance(_ context.Context, address common.Address, minimum *big.Int) error { + f.address = address + f.minimum = new(big.Int).Set(minimum) + return nil +} + +func TestMiningWithPausedUsesFreshContextToResume(t *testing.T) { + controller := &recordingMining{} + mining := bindTestMining(controller) + ctx, cancel := context.WithCancel(t.Context()) + + err := mining.WithPaused(ctx, func() error { + cancel() + return context.Canceled + }) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, controller.pauses) + require.Equal(t, 1, controller.resumes) + require.NoError(t, controller.resumeContextError, "resume must not inherit callback cancellation") +} + +type recordingMining struct { + pauses int + resumes int + resumeContextError error +} + +func (m *recordingMining) PauseMining(context.Context) error { + m.pauses++ + return nil +} + +func (m *recordingMining) ResumeMining(ctx context.Context) error { + m.resumes++ + m.resumeContextError = ctx.Err() + return nil +} + +func (*recordingMining) MineBlocks(context.Context, int) error { return nil } +func (*recordingMining) AdvanceTime(context.Context, time.Duration) error { return nil } + +func TestMiningWithPausedJoinsCallbackAndResumeFailures(t *testing.T) { + callbackErr := errors.New("callback") + resumeErr := errors.New("resume") + controller := &failingResumeMining{resumeErr: resumeErr} + err := bindTestMining(controller).WithPaused( + t.Context(), + func() error { return callbackErr }, + ) + require.ErrorIs(t, err, callbackErr) + require.ErrorIs(t, err, resumeErr) +} + +type failingResumeMining struct{ resumeErr error } + +func (*failingResumeMining) PauseMining(context.Context) error { return nil } +func (m *failingResumeMining) ResumeMining(context.Context) error { return m.resumeErr } +func (*failingResumeMining) MineBlocks(context.Context, int) error { return nil } +func (*failingResumeMining) AdvanceTime(context.Context, time.Duration) error { return nil } + +func bindTestMining(controller chainimpl.BlockController) *Mining { + mining := &Mining{controller: controller} + chain := &Chain{id: "test", mining: mining} + chain.bindLease(&environmentLease{}) + return mining +} diff --git a/e2e/internal/harness/environment/environment.go b/e2e/internal/harness/environment/environment.go new file mode 100644 index 000000000..51c388dd9 --- /dev/null +++ b/e2e/internal/harness/environment/environment.go @@ -0,0 +1,150 @@ +package environment + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" +) + +// Environment is returned only after every supported declaration is ready. It +// is the sole owner of acquired local handles and environment-owned resources. +type Environment struct { + chains map[ChainID]*Chain + instances map[IBCInstanceID]*IBCInstance + connections map[ConnectionID]*Connection + clients map[ClientID]*IBCClient + attestors map[AttestorID]*Attestor + relayers map[RelayerID]*Relayer + + runtime Runtime + + effects *effectJournal + ws workspace + + lease *environmentLease + + closeMu sync.Mutex + closed bool +} + +func (e *Environment) Chain(id ChainID) (*Chain, error) { + chain, ok := e.chains[id] + if !ok { + return nil, fmt.Errorf("environment: no Chain %q", id) + } + return chain, nil +} + +// Chains returns the resolved Chain identities in stable order. The returned +// slice is owned by the caller. +func (e *Environment) Chains() []ChainID { + ids := make([]ChainID, 0, len(e.chains)) + for id := range e.chains { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +func (e *Environment) IBCInstance(id IBCInstanceID) (*IBCInstance, error) { + instance, ok := e.instances[id] + if !ok { + return nil, fmt.Errorf("environment: no IBC Instance %q", id) + } + return instance, nil +} + +func (e *Environment) Connection(id ConnectionID) (*Connection, error) { + connection, ok := e.connections[id] + if !ok { + return nil, fmt.Errorf("environment: no IBC Connection %q", id) + } + return connection, nil +} + +func (e *Environment) IBCClient(id ClientID) (*IBCClient, error) { + client, ok := e.clients[id] + if !ok { + return nil, fmt.Errorf("environment: no IBC Client %q", id) + } + return client, nil +} + +func (e *Environment) Attestor(id AttestorID) (*Attestor, error) { + attestor, ok := e.attestors[id] + if !ok { + return nil, fmt.Errorf("environment: no Attestor %q", id) + } + return attestor, nil +} + +func (e *Environment) Relayer(id RelayerID) (*Relayer, error) { + relayer, ok := e.relayers[id] + if !ok { + return nil, fmt.Errorf("environment: no Relayer %q", id) + } + return relayer, nil +} + +// Close releases acquired effects in reverse acquisition order, then removes +// the private workspace. Successful effects are never repeated. If cleanup +// fails or ctx expires, a later call retries only unfinished effects. +func (e *Environment) Close(ctx context.Context) error { + e.closeMu.Lock() + defer e.closeMu.Unlock() + if e.closed { + return nil + } + if err := e.lease.close(ctx); err != nil { + return fmt.Errorf("environment: wait for active operations before cleanup: %w", err) + } + + cleanupErrs := e.effects.cleanup(ctx) + if len(cleanupErrs) != 0 { + return errors.Join(cleanupErrs...) + } + if err := e.ws.remove(); err != nil { + return fmt.Errorf("environment cleanup workspace removal failed: %w", err) + } + e.closed = true + return nil +} + +type cleanupEffect struct { + description string + release func(context.Context) error + done bool +} + +// effectJournal tracks acquired cleanup work in reverse-release order. +type effectJournal struct { + mu sync.Mutex + effects []cleanupEffect +} + +func (j *effectJournal) append(effect cleanupEffect) { + j.mu.Lock() + defer j.mu.Unlock() + j.effects = append(j.effects, effect) +} + +func (j *effectJournal) cleanup(ctx context.Context) []error { + j.mu.Lock() + defer j.mu.Unlock() + + failures := make([]error, 0) + for i := len(j.effects) - 1; i >= 0; i-- { + effect := &j.effects[i] + if effect.done { + continue + } + if err := effect.release(ctx); err != nil { + failures = append(failures, fmt.Errorf("environment cleanup %s failed: %w", effect.description, err)) + continue + } + effect.done = true + } + return failures +} diff --git a/e2e/internal/harness/environment/environment_test.go b/e2e/internal/harness/environment/environment_test.go new file mode 100644 index 000000000..3d5c9ced0 --- /dev/null +++ b/e2e/internal/harness/environment/environment_test.go @@ -0,0 +1,21 @@ +package environment + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEnvironmentChainsReturnsStableCallerOwnedIDs(t *testing.T) { + env := &Environment{chains: map[ChainID]*Chain{ + "charlie": {}, + "alpha": {}, + "bravo": {}, + }} + + ids := env.Chains() + require.Equal(t, []ChainID{"alpha", "bravo", "charlie"}, ids) + + ids[0] = "changed-by-caller" + require.Equal(t, []ChainID{"alpha", "bravo", "charlie"}, env.Chains()) +} diff --git a/e2e/internal/harness/environment/evm_timing_test.go b/e2e/internal/harness/environment/evm_timing_test.go new file mode 100644 index 000000000..ac18bf632 --- /dev/null +++ b/e2e/internal/harness/environment/evm_timing_test.go @@ -0,0 +1,220 @@ +package environment + +import ( + "context" + "encoding/json" + "math/big" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + chainevm "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" +) + +func TestResolvedEVMWaitPendingTxUsesCompletionBudget(t *testing.T) { + service := &timedEthService{} + evmAccess := resolvedEVMForTimingTest(t, Timing{ + CompletionBudget: 30 * time.Millisecond, + PollInterval: 5 * time.Millisecond, + }, service) + + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + started := time.Now() + err := evmAccess.WaitPendingTx(ctx) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Less(t, time.Since(started), 250*time.Millisecond) + require.GreaterOrEqual(t, service.pendingCallCount(), 2) +} + +func TestResolvedEVMWaitPendingTxUsesPollInterval(t *testing.T) { + const readyAfter = 5 + service := &timedEthService{pendingReadyAfter: readyAfter} + evmAccess := resolvedEVMForTimingTest(t, Timing{ + CompletionBudget: 250 * time.Millisecond, + PollInterval: 20 * time.Millisecond, + }, service) + + started := time.Now() + err := evmAccess.WaitPendingTx(t.Context()) + + require.NoError(t, err) + require.Equal(t, readyAfter, service.pendingCallCount()) + require.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond) +} + +func TestResolvedEVMBroadcastUsesCompletionBudget(t *testing.T) { + service := &timedEthService{} + evmAccess := resolvedEVMForTimingTest(t, Timing{ + CompletionBudget: 30 * time.Millisecond, + PollInterval: 5 * time.Millisecond, + }, service) + sender, err := chainevm.AccountFromHex( + "0000000000000000000000000000000000000000000000000000000000000007", + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + started := time.Now() + _, err = evmAccess.BroadcastTx(ctx, sender, nil, []byte{0x00}, nil) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Less(t, time.Since(started), 250*time.Millisecond) +} + +func TestResolvedEVMBroadcastUsesPollIntervalWithinCompletionBudget(t *testing.T) { + const readyAfter = 8 + service := &timedEthService{receiptReadyAfter: readyAfter} + evmAccess := resolvedEVMForTimingTest(t, Timing{ + CompletionBudget: 250 * time.Millisecond, + PollInterval: 20 * time.Millisecond, + }, service) + sender, err := chainevm.AccountFromHex( + "0000000000000000000000000000000000000000000000000000000000000007", + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 400*time.Millisecond) + defer cancel() + started := time.Now() + receipt, err := evmAccess.BroadcastTx(ctx, sender, nil, []byte{0x00}, nil) + + require.NoError(t, err) + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + require.Equal(t, readyAfter, service.receiptCallCount()) + require.GreaterOrEqual(t, time.Since(started), 100*time.Millisecond) +} + +type timingEVMAdapter struct { + chainevm.Identity + client *chainevm.EVMClient +} + +func (a *timingEVMAdapter) WithEVMClient(use func(*chainevm.EVMClient) error) error { + return use(a.client) +} + +func (a *timingEVMAdapter) Height(ctx context.Context) (uint64, error) { + return a.client.Height(ctx) +} + +func resolvedEVMForTimingTest(t *testing.T, timing Timing, service *timedEthService) *EVM { + t.Helper() + + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", service)) + rpcClient := rpc.DialInProc(server) + client, err := chainevm.NewConnectedClient(t.Context(), ethclient.NewClient(rpcClient)) + require.NoError(t, err) + t.Cleanup(func() { + client.Close() + server.Stop() + }) + + chain := &Chain{ + id: "timed-chain", + timing: timing, + impl: &timingEVMAdapter{Identity: chainevm.NewIdentity("timed-chain", "in-process"), client: client}, + } + evmAccess, err := chain.EVM() + require.NoError(t, err) + return evmAccess +} + +type timedEthService struct { + mu sync.Mutex + + pendingCalls int + pendingReadyAfter int + receiptCalls int + receiptReadyAfter int +} + +func (*timedEthService) ChainId() hexutil.Uint64 { //nolint:revive // JSON-RPC requires eth_chainId. + return 31337 +} + +func (s *timedEthService) GetBlockTransactionCountByNumber(string) hexutil.Uint { + s.mu.Lock() + defer s.mu.Unlock() + s.pendingCalls++ + if s.pendingReadyAfter > 0 && s.pendingCalls >= s.pendingReadyAfter { + return 1 + } + return 0 +} + +func (*timedEthService) GetTransactionCount(common.Address, string) hexutil.Uint64 { return 0 } + +func (*timedEthService) EstimateGas(map[string]any) hexutil.Uint64 { return 21_000 } + +func (*timedEthService) MaxPriorityFeePerGas() *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1_000_000_000)) +} + +func (*timedEthService) GetBlockByNumber(string, bool) *types.Header { + return &types.Header{ + UncleHash: types.EmptyUncleHash, + TxHash: types.EmptyTxsHash, + ReceiptHash: types.EmptyReceiptsHash, + Difficulty: big.NewInt(1), + Number: big.NewInt(1), + GasLimit: 30_000_000, + Time: 1, + BaseFee: big.NewInt(1), + } +} + +func (*timedEthService) SendRawTransaction(raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + return tx.Hash(), nil +} + +// GetTransactionReceipt returns a JSON-RPC result rather than *types.Receipt so +// the not-ready case can serialize as JSON null: go-ethereum's server panics +// marshaling a typed-nil *types.Receipt (its MarshalJSON has a value receiver), +// and ethclient reads a null result as ethereum.NotFound, which the client's +// receipt poll treats as "keep waiting". +func (s *timedEthService) GetTransactionReceipt(hash common.Hash) (json.RawMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.receiptCalls++ + if s.receiptReadyAfter == 0 || s.receiptCalls < s.receiptReadyAfter { + return json.RawMessage("null"), nil + } + return json.Marshal(&types.Receipt{ + Type: types.DynamicFeeTxType, + Status: types.ReceiptStatusSuccessful, + CumulativeGasUsed: 21_000, + Logs: []*types.Log{}, + TxHash: hash, + GasUsed: 21_000, + EffectiveGasPrice: big.NewInt(1_000_000_001), + BlockHash: common.HexToHash("0x1"), + BlockNumber: big.NewInt(1), + }) +} + +func (s *timedEthService) pendingCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.pendingCalls +} + +func (s *timedEthService) receiptCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.receiptCalls +} diff --git a/e2e/internal/harness/environment/failure.go b/e2e/internal/harness/environment/failure.go new file mode 100644 index 000000000..345e147a6 --- /dev/null +++ b/e2e/internal/harness/environment/failure.go @@ -0,0 +1,41 @@ +package environment + +import ( + "errors" +) + +// StartError reports a realization failure and any cleanup failures. +type StartError struct { + cause error + cleanup error + diagnosticsDir string +} + +func newStartError( + cause error, + diagnosticsDir string, + cleanupFailures ...error, +) *StartError { + return &StartError{ + cause: cause, + cleanup: errors.Join(cleanupFailures...), + diagnosticsDir: diagnosticsDir, + } +} + +func (e *StartError) Error() string { + message := "environment start failed: " + e.cause.Error() + if e.cleanup != nil { + message += "; cleanup also failed: " + e.cleanup.Error() + } + return message +} + +func (e *StartError) Unwrap() error { return e.cause } + +func (e *StartError) CleanupError() error { return e.cleanup } + +// DiagnosticsDir is non-empty only when failed cleanup forced Start to retain +// diagnostic artifacts. Runtime-private workspace files are never placed in +// the returned directory. +func (e *StartError) DiagnosticsDir() string { return e.diagnosticsDir } diff --git a/e2e/internal/harness/environment/failure_test.go b/e2e/internal/harness/environment/failure_test.go new file mode 100644 index 000000000..4bd5f6847 --- /dev/null +++ b/e2e/internal/harness/environment/failure_test.go @@ -0,0 +1,29 @@ +package environment + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStartErrorPreservesCauseCleanupAndDiagnostics(t *testing.T) { + primary := errors.New("startup failed") + cleanup := errors.New("cleanup failed") + err := newStartError(primary, "/tmp/diagnostics", cleanup) + + require.ErrorIs(t, err, primary) + require.ErrorIs(t, err.CleanupError(), cleanup) + require.Equal(t, "/tmp/diagnostics", err.DiagnosticsDir()) + require.ErrorContains(t, err, "environment start failed: startup failed") + require.ErrorContains(t, err, "cleanup failed") +} + +func TestStartErrorWithoutCleanup(t *testing.T) { + primary := errors.New("startup failed") + err := newStartError(primary, "") + + require.ErrorIs(t, err, primary) + require.NoError(t, err.CleanupError()) + require.Empty(t, err.DiagnosticsDir()) +} diff --git a/e2e/internal/harness/environment/lease.go b/e2e/internal/harness/environment/lease.go new file mode 100644 index 000000000..01f765418 --- /dev/null +++ b/e2e/internal/harness/environment/lease.go @@ -0,0 +1,68 @@ +package environment + +import ( + "context" + "sync" +) + +// environmentLease linearizes resolved operations and bound child processes +// with Environment cleanup. +type environmentLease struct { + mu sync.Mutex + closed bool + active int + drained chan struct{} +} + +func (l *environmentLease) use(use func() error) error { + release, err := l.acquire() + if err != nil { + return err + } + defer release() + return use() +} + +func (l *environmentLease) acquire() (func(), error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil, ErrEnvironmentClosed + } + l.active++ + var once sync.Once + return func() { + once.Do(l.release) + }, nil +} + +func (l *environmentLease) release() { + l.mu.Lock() + defer l.mu.Unlock() + l.active-- + if l.closed && l.active == 0 && l.drained != nil { + close(l.drained) + l.drained = nil + } +} + +func (l *environmentLease) close(ctx context.Context) error { + l.mu.Lock() + l.closed = true + if l.active == 0 { + l.mu.Unlock() + return nil + } + if l.drained == nil { + l.drained = make(chan struct{}) + } + drained := l.drained + l.mu.Unlock() + + select { + case <-drained: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/e2e/internal/harness/environment/protocol.go b/e2e/internal/harness/environment/protocol.go new file mode 100644 index 000000000..999037eac --- /dev/null +++ b/e2e/internal/harness/environment/protocol.go @@ -0,0 +1,154 @@ +package environment + +import ( + "context" + "slices" + + "github.com/cosmos/ibc/e2e/internal/harness/ibclink" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" + "github.com/cosmos/ibc/e2e/internal/harness/relayerproc" +) + +// EVMAddress is a non-secret, checksummed contract or signer address returned +// by the concrete EVM realization. It remains a string at the Environment +// boundary so workflows do not need the deployment adapter's Go types. +type EVMAddress string + +// IBCInstance is a ready ICS26 installation on one resolved Chain. +type IBCInstance struct { + id IBCInstanceID + chain *Chain + locator IBCInstanceLocator + accessManager EVMAddress +} + +func (i *IBCInstance) ID() IBCInstanceID { return i.id } +func (i *IBCInstance) Chain() *Chain { return i.chain } +func (i *IBCInstance) Locator() IBCInstanceLocator { return i.locator } +func (i *IBCInstance) AccessManagerAddress() EVMAddress { + return i.accessManager +} + +// IBCClient is one resolved end of an IBC Connection. ID is the stable +// authored identity; Locator is the actual client identifier registered in +// the host IBC Instance. +type IBCClient struct { + id ClientID + instance *IBCInstance + locator IBCClientLocator + lightClient EVMAddress + counterparty IBCClientLocator + attestors []EVMAddress + minRequiredSignatures uint8 +} + +func (c *IBCClient) ID() ClientID { return c.id } +func (c *IBCClient) IBCInstance() *IBCInstance { return c.instance } +func (c *IBCClient) Locator() IBCClientLocator { return c.locator } +func (c *IBCClient) LightClientAddress() EVMAddress { return c.lightClient } +func (c *IBCClient) CounterpartyLocator() IBCClientLocator { return c.counterparty } +func (c *IBCClient) AttestorAddresses() []EVMAddress { return slices.Clone(c.attestors) } +func (c *IBCClient) MinRequiredSignatures() uint8 { return c.minRequiredSignatures } + +// Connection is a ready reciprocal IBC Client pair. A and B preserve the +// authored end labels; callers can also locate either Client directly through +// Environment.IBCClient. +type Connection struct { + id ConnectionID + a *IBCClient + b *IBCClient +} + +func (c *Connection) ID() ConnectionID { return c.id } +func (c *Connection) A() *IBCClient { return c.a } +func (c *Connection) B() *IBCClient { return c.b } + +// Attestor is one running, config-loaded IBC Link Attestor process. +// ObservedIBCInstance is derived from the counterparty end of the Client's +// Connection. +// +// Its startup probe does not establish chain-derived attestation or proof +// capability, so this value intentionally exposes neither until the product +// process can provide them truthfully. +type Attestor struct { + id AttestorID + client *IBCClient + observed *IBCInstance + signer EVMAddress + grpc string + process *ibclink.AttestorProcess +} + +func (a *Attestor) ID() AttestorID { return a.id } +func (a *Attestor) IBCClient() *IBCClient { return a.client } +func (a *Attestor) ObservedIBCInstance() *IBCInstance { return a.observed } +func (a *Attestor) SignerAddress() EVMAddress { return a.signer } + +// GRPCEndpoint is the loopback host:port a Relayer uses to reach this Attestor. +// It is empty for an embedded Attestor, which is served in-process by its +// Relayer (dual mode) and has no separate process or network endpoint. +func (a *Attestor) GRPCEndpoint() string { return a.grpc } + +// Stop terminates the Attestor's `ibc attestor run` process (SIGTERM escalating +// to SIGKILL after a grace period) and waits for it to exit. It is safe to call +// more than once and is a no-op for an embedded Attestor, which has no process +// of its own. The Environment's cleanup still runs; a second stop is idempotent. +func (a *Attestor) Stop(ctx context.Context) error { + if a.process == nil { + return nil + } + return a.process.Stop(ctx) +} + +// Relayer is one running `ibc relayer run` process serving both directions of +// every authored IBC Connection it was given. Its startup probe establishes that +// the process loaded its configuration, connected its Chains, subscribed to +// routes, and answered /health; it does not by itself prove any packet has been +// relayed. +type Relayer struct { + id RelayerID + connections []*Connection + signer EVMAddress + process *relayerproc.Relayer +} + +func (r *Relayer) ID() RelayerID { return r.id } + +// ConnectionIDs are the IBC Connections this Relayer serves, in declaration order. +func (r *Relayer) ConnectionIDs() []ConnectionID { + ids := make([]ConnectionID, len(r.connections)) + for i, connection := range r.connections { + ids[i] = connection.id + } + return ids +} + +func (r *Relayer) SignerAddress() EVMAddress { return r.signer } + +// Ready returns the readiness event the process reported on startup. +func (r *Relayer) Ready() wire.Readiness { return r.process.Ready() } + +// Status queries the relayer's /status API. The zero query asks for everything. +func (r *Relayer) Status(ctx context.Context, query wire.StatusQuery) (*wire.Status, error) { + return r.process.Status(ctx, query) +} + +// Relay submits a manual relay request to the relayer's /relay API. +func (r *Relayer) Relay(ctx context.Context, request wire.RelayRequest) (*wire.RelayResult, error) { + return r.process.Relay(ctx, request) +} + +// Restart stops the Relayer's `ibc relayer run` process and relaunches it against +// the same on-disk workspace (config, signer key, and database), so relay state +// persisted before the stop survives. It returns only after the new process +// reports readiness and answers /health. Subsequent Status and Relay calls reach +// the restarted process even if its API bind port changed. +func (r *Relayer) Restart(ctx context.Context) error { + return r.process.Restart(ctx) +} + +// Kill terminates the Relayer's process group immediately and waits for the +// reap. It models an abrupt crash for restart-recovery scenarios. +func (r *Relayer) Kill() error { + return r.process.Kill() +} diff --git a/e2e/internal/harness/environment/protocol_start_test.go b/e2e/internal/harness/environment/protocol_start_test.go new file mode 100644 index 000000000..82af3a2cd --- /dev/null +++ b/e2e/internal/harness/environment/protocol_start_test.go @@ -0,0 +1,273 @@ +package environment + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestStartRealizesMixedProtocolGraphWithInjectedDrivers(t *testing.T) { + spec := mixedProtocolSpec() + runtime := mixedProtocolRuntime() + attestorAccount, err := runtime.evmAccount("attestor-signer") + require.NoError(t, err) + + var ( + chainsReady atomic.Int32 + instancesReady atomic.Int32 + connectionReady atomic.Bool + releaseMu sync.Mutex + releases []string + ) + recordRelease := func(resource string) { + releaseMu.Lock() + defer releaseMu.Unlock() + releases = append(releases, resource) + } + + env, err := start(t.Context(), spec, runtime, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + chainsReady.Add(1) + id := declaration.chainID() + return fakeAcquisition(id, func(context.Context) error { + recordRelease("chain:" + string(id)) + return nil + }), nil + }, + acquireIBCInstance: func(_ context.Context, declaration IBCInstanceSpec, chain *Chain, _ Runtime) (*IBCInstance, error) { + require.EqualValues(t, 2, chainsReady.Load()) + require.Equal(t, declaration.ibcInstanceChain(), chain.ID()) + instancesReady.Add(1) + switch instance := declaration.(type) { + case NewIBCInstance: + return &IBCInstance{id: instance.ID, chain: chain, locator: "router-a"}, nil + case ExistingIBCInstance: + return &IBCInstance{id: instance.ID, chain: chain, locator: instance.Locator}, nil + default: + return nil, errors.New("unexpected IBC Instance declaration") + } + }, + acquireConnection: func(_ context.Context, declaration ConnectionSpec, dependencies connectionDependencies, _ Runtime) (*Connection, error) { + require.EqualValues(t, 2, instancesReady.Load()) + require.Equal(t, []AttestorSpec{spec.Attestors[0]}, dependencies.attestorSpecs["client-a"]) + connectionReady.Store(true) + return resolvedMixedConnection( + declaration.ID, + dependencies.instances, + EVMAddress(attestorAccount.Address().Hex()), + ), nil + }, + acquireAttestor: func(_ context.Context, declaration AttestorSpec, dependencies attestorDependencies, _ Runtime, _ workspace) (attestorAcquisition, error) { + require.True(t, connectionReady.Load()) + require.Equal(t, ClientID("client-a"), dependencies.client.ID()) + require.Equal(t, IBCInstanceID("ibc-b"), dependencies.observed.ID()) + return attestorAcquisition{ + attestor: &Attestor{ + id: declaration.ID, client: dependencies.client, observed: dependencies.observed, + signer: EVMAddress(attestorAccount.Address().Hex()), + }, + description: "stop Attestor " + string(declaration.ID), + release: func(context.Context) error { + recordRelease("attestor:" + string(declaration.ID)) + return nil + }, + }, nil + }, + }) + require.NoError(t, err) + + connection, err := env.Connection("connection-ab") + require.NoError(t, err) + require.Equal(t, IBCClientLocator("existing-client-b"), connection.A().CounterpartyLocator()) + require.Same(t, connection.A(), mustIBCClient(t, env, "client-a")) + + attestor, err := env.Attestor("attestor-a") + require.NoError(t, err) + require.Same(t, connection.A(), attestor.IBCClient()) + require.Equal(t, IBCInstanceID("ibc-b"), attestor.ObservedIBCInstance().ID()) + + require.NoError(t, env.Close(t.Context())) + releaseMu.Lock() + gotReleases := append([]string(nil), releases...) + releaseMu.Unlock() + require.Equal(t, "attestor:attestor-a", gotReleases[0]) + require.ElementsMatch(t, []string{"chain:chain-a", "chain:chain-b"}, gotReleases[1:]) +} + +func TestConnectionFailureStopsBeforeAttestorsAndCleansChains(t *testing.T) { + spec := mixedProtocolSpec() + connectionErr := errors.New("connection failed") + var releases atomic.Int32 + attestorStarted := false + + env, err := start(t.Context(), spec, mixedProtocolRuntime(), drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + return fakeAcquisition(declaration.chainID(), func(context.Context) error { + releases.Add(1) + return nil + }), nil + }, + acquireIBCInstance: func(_ context.Context, declaration IBCInstanceSpec, chain *Chain, _ Runtime) (*IBCInstance, error) { + return &IBCInstance{ + id: declaration.ibcInstanceID(), chain: chain, locator: "router", + }, nil + }, + acquireConnection: func(context.Context, ConnectionSpec, connectionDependencies, Runtime) (*Connection, error) { + return nil, connectionErr + }, + acquireAttestor: func(context.Context, AttestorSpec, attestorDependencies, Runtime, workspace) (attestorAcquisition, error) { + attestorStarted = true + return attestorAcquisition{}, nil + }, + }) + + require.Nil(t, env) + require.ErrorIs(t, err, connectionErr) + require.False(t, attestorStarted) + require.EqualValues(t, len(spec.Chains), releases.Load()) +} + +func TestResolvedClientsRejectAttestorSignerReuse(t *testing.T) { + const signer EVMAddress = "0x1000000000000000000000000000000000000001" + seen := make(map[EVMAddress]ClientID) + require.NoError(t, recordResolvedAttestorUse(seen, &IBCClient{id: "client-a", attestors: []EVMAddress{signer}})) + err := recordResolvedAttestorUse(seen, &IBCClient{id: "client-b", attestors: []EVMAddress{signer}}) + require.ErrorContains(t, err, `attestor signer 0x1000000000000000000000000000000000000001 is reused`) +} + +func TestExistingClientRequiresDeclaredAttestorSignerMembership(t *testing.T) { + runtime := Runtime{Authorities: map[AuthorityID]EVMAuthority{ + "registered": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + "absent": {PrivateKeyHex: testSecondaryPrivateKeyHex}, + }} + registered, err := runtime.evmAccount("registered") + require.NoError(t, err) + onChain := []common.Address{registered.Address()} + + require.NoError(t, requireDeclaredAttestors("client-a", onChain, []AttestorSpec{{ + ID: "attestor-a", Client: "client-a", Authority: "registered", + }}, runtime)) + err = requireDeclaredAttestors("client-a", onChain, []AttestorSpec{{ + ID: "attestor-a", Client: "client-a", Authority: "absent", + }}, runtime) + require.ErrorContains(t, err, `is not registered for existing IBC Client "client-a"`) +} + +func TestFailedAttestorStartRetainsPartialCleanup(t *testing.T) { + spec := mixedProtocolSpec() + instances := map[IBCInstanceID]*IBCInstance{ + "ibc-a": {id: "ibc-a"}, + "ibc-b": {id: "ibc-b"}, + } + clients := map[ClientID]*IBCClient{ + "client-a": {id: "client-a", instance: instances["ibc-a"]}, + } + effects := &effectJournal{} + var releases atomic.Int32 + startErr := errors.New("readiness failed") + + _, err := acquireAttestors( + t.Context(), + spec, + instances, + clients, + mixedProtocolRuntime(), + workspace{privateDir: t.TempDir(), diagnosticsDir: t.TempDir()}, + drivers{acquireAttestor: func( + context.Context, + AttestorSpec, + attestorDependencies, + Runtime, + workspace, + ) (attestorAcquisition, error) { + return attestorAcquisition{ + description: "stop partial Attestor", + release: func(context.Context) error { + releases.Add(1) + return nil + }, + }, startErr + }}, + effects, + ) + require.ErrorIs(t, err, startErr) + require.Empty(t, effects.cleanup(t.Context())) + require.EqualValues(t, 1, releases.Load()) +} + +func TestProtocolDeclarationsUseCanonicalIdentityOrder(t *testing.T) { + instances := sortedIBCInstanceSpecs([]IBCInstanceSpec{ExistingIBCInstance{ID: "z"}, ExistingIBCInstance{ID: "a"}}) + require.Equal( + t, + []IBCInstanceID{"a", "z"}, + []IBCInstanceID{instances[0].ibcInstanceID(), instances[1].ibcInstanceID()}, + ) + + connections := sortedConnectionSpecs([]ConnectionSpec{{ID: "z"}, {ID: "a"}}) + require.Equal(t, []ConnectionID{"a", "z"}, []ConnectionID{connections[0].ID, connections[1].ID}) + + attestors := sortedAttestorSpecs([]AttestorSpec{{ID: "z"}, {ID: "a"}}) + require.Equal(t, []AttestorID{"a", "z"}, []AttestorID{attestors[0].ID, attestors[1].ID}) +} + +func mixedProtocolSpec() Spec { + return Spec{ + Chains: []ChainSpec{ + ManagedAnvil{ID: "chain-a", EVMChainID: 31337}, + AttachedEVM{ID: "chain-b", EVMChainID: 31338, Endpoint: "chain-b-rpc", Timing: testTiming()}, + }, + IBCInstances: []IBCInstanceSpec{ + NewIBCInstance{ID: "ibc-a", Chain: "chain-a", Authority: "instance-owner"}, + ExistingIBCInstance{ID: "ibc-b", Chain: "chain-b", Locator: "0x1000000000000000000000000000000000000001"}, + }, + Connections: []ConnectionSpec{{ + ID: "connection-ab", + A: NewClient{ID: "client-a", IBCInstance: "ibc-a", Authority: "client-owner", MinRequiredSignatures: 1}, + B: ExistingClient{ID: "client-b", IBCInstance: "ibc-b", Locator: "existing-client-b"}, + }}, + Attestors: []AttestorSpec{{ID: "attestor-a", Client: "client-a", Authority: "attestor-signer"}}, + } +} + +func mixedProtocolRuntime() Runtime { + return Runtime{ + Endpoints: map[EndpointBindingID]EndpointBinding{ + "chain-b-rpc": {RPCURL: "http://127.0.0.1:8546"}, + }, + Authorities: map[AuthorityID]EVMAuthority{ + "instance-owner": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + "client-owner": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + "attestor-signer": {PrivateKeyHex: testSecondaryPrivateKeyHex}, + }, + } +} + +func resolvedMixedConnection( + id ConnectionID, + instances map[IBCInstanceID]*IBCInstance, + attestor EVMAddress, +) *Connection { + a := &IBCClient{ + id: "client-a", instance: instances["ibc-a"], locator: "client-a-onchain", + lightClient: "0x2000000000000000000000000000000000000002", counterparty: "existing-client-b", + attestors: []EVMAddress{attestor}, minRequiredSignatures: 1, + } + b := &IBCClient{ + id: "client-b", instance: instances["ibc-b"], locator: "existing-client-b", + lightClient: "0x3000000000000000000000000000000000000003", counterparty: "client-a-onchain", + minRequiredSignatures: 1, + } + return &Connection{id: id, a: a, b: b} +} + +func mustIBCClient(t *testing.T, env *Environment, id ClientID) *IBCClient { + t.Helper() + client, err := env.IBCClient(id) + require.NoError(t, err) + return client +} diff --git a/e2e/internal/harness/environment/realize_protocol.go b/e2e/internal/harness/environment/realize_protocol.go new file mode 100644 index 000000000..40793b1dd --- /dev/null +++ b/e2e/internal/harness/environment/realize_protocol.go @@ -0,0 +1,949 @@ +package environment + +import ( + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "path/filepath" + "slices" + "strconv" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/environment/solidityibc" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink" + + managedrelayer "github.com/cosmos/ibc/e2e/internal/harness/relayerproc" +) + +// relayerFinalityOffset is the counterparty-chain finality offset written into +// relayer client configuration. Managed Attestors observe with native finality +// (no configured offset), so the Relayer must request the latest attestable +// height, i.e. offset zero. Model finality separately here when a workflow +// needs a non-instant Chain. +const relayerFinalityOffset uint64 = 0 + +type connectionDependencies struct { + instances map[IBCInstanceID]*IBCInstance + attestorSpecs map[ClientID][]AttestorSpec + existingClients map[ClientID]*IBCClient + preparedClients map[ClientID]*solidityibc.PreparedClient +} + +type attestorDependencies struct { + client *IBCClient + observed *IBCInstance + embedded bool +} + +func acquireIBCInstances( + ctx context.Context, + declarations []IBCInstanceSpec, + chains map[ChainID]*Chain, + runtime Runtime, + d drivers, +) (map[IBCInstanceID]*IBCInstance, error) { + instances := make(map[IBCInstanceID]*IBCInstance, len(declarations)) + for _, declaration := range declarations { + id := declaration.ibcInstanceID() + instance, err := d.acquireIBCInstance( + ctx, + declaration, + chains[declaration.ibcInstanceChain()], + runtime, + ) + if err != nil { + return nil, fmt.Errorf("start IBC Instance %q failed: %w", id, err) + } + if instance == nil { + return nil, fmt.Errorf("environment: IBC Instance %q adapter returned no resolved value", id) + } + instances[id] = instance + } + return instances, nil +} + +func acquireConnections( + ctx context.Context, + spec Spec, + instances map[IBCInstanceID]*IBCInstance, + runtime Runtime, + d drivers, +) ( + map[ConnectionID]*Connection, + map[ClientID]*IBCClient, + error, +) { + connections := make(map[ConnectionID]*Connection, len(spec.Connections)) + clients := make(map[ClientID]*IBCClient, 2*len(spec.Connections)) + attestorUse := make(map[EVMAddress]ClientID) + attestorSpecsByClient := make(map[ClientID][]AttestorSpec) + for _, declaration := range spec.Attestors { + attestorSpecsByClient[declaration.Client] = append( + attestorSpecsByClient[declaration.Client], + declaration, + ) + } + dependencies := connectionDependencies{ + instances: instances, attestorSpecs: attestorSpecsByClient, + } + if d.prepareConnections != nil { + prepared, err := d.prepareConnections(ctx, spec, dependencies, runtime) + if err != nil { + return nil, nil, fmt.Errorf("prepare IBC Connections failed: %w", err) + } + dependencies = prepared + } + + for _, declaration := range spec.Connections { + connection, err := d.acquireConnection(ctx, declaration, dependencies, runtime) + if err != nil { + return nil, nil, fmt.Errorf("start IBC Connection %q failed: %w", declaration.ID, err) + } + if connection == nil || connection.a == nil || connection.b == nil { + return nil, nil, fmt.Errorf( + "environment: IBC Connection %q adapter returned an incomplete resolved value", + declaration.ID, + ) + } + if err := recordResolvedAttestorUse(attestorUse, connection.a, connection.b); err != nil { + return nil, nil, err + } + connections[declaration.ID] = connection + clients[connection.a.id] = connection.a + clients[connection.b.id] = connection.b + } + return connections, clients, nil +} + +func recordResolvedAttestorUse(seen map[EVMAddress]ClientID, clients ...*IBCClient) error { + for _, client := range clients { + for _, address := range client.attestors { + if previous, exists := seen[address]; exists { + return fmt.Errorf( + "attestor signer %s is reused by resolved IBC Clients %q and %q", + address, + previous, + client.id, + ) + } + seen[address] = client.id + } + } + return nil +} + +func acquireAttestors( + ctx context.Context, + spec Spec, + instances map[IBCInstanceID]*IBCInstance, + clients map[ClientID]*IBCClient, + runtime Runtime, + ws workspace, + d drivers, + effects *effectJournal, +) (map[AttestorID]*Attestor, error) { + foldedOnly := foldedOnlyAttestorIDs(spec) + attestors := make(map[AttestorID]*Attestor, len(spec.Attestors)) + for _, declaration := range spec.Attestors { + observed, err := observedInstanceForClient(spec.Connections, declaration.Client, instances) + if err != nil { + return nil, err + } + _, isEmbedded := foldedOnly[declaration.ID] + acquisition, err := d.acquireAttestor(ctx, declaration, attestorDependencies{ + client: clients[declaration.Client], observed: observed, embedded: isEmbedded, + }, runtime, ws) + if err != nil { + if acquisition.release != nil { + effects.append(cleanupEffect{ + description: acquisition.description, + release: acquisition.release, + }) + } + return nil, fmt.Errorf("start Attestor %q failed: %w", declaration.ID, err) + } + if acquisition.attestor == nil || acquisition.release == nil { + return nil, fmt.Errorf( + "environment: Attestor %q adapter returned an incomplete acquisition", + declaration.ID, + ) + } + effects.append(cleanupEffect{ + description: acquisition.description, + release: acquisition.release, + }) + attestors[declaration.ID] = acquisition.attestor + } + return attestors, nil +} + +// foldedOnlyAttestorIDs returns the Attestors that are served purely in-process +// by a dual-mode Relayer and therefore need no separate `ibc attestor run` +// process. An Attestor is a candidate for folding when it is bound to a Client +// of a Connection served by a Relayer with EmbedAttestors set. +// +// Suppression is per-need, not global: folding one Relayer's copy must not hide +// the process from a second, non-embedding Relayer that still reaches the same +// Attestor over gRPC. An Attestor is folded-only (no process) exactly when every +// Relayer referencing it embeds it; if any non-embedding Relayer references it, +// the standalone process still starts and the embedding Relayer simply renders +// its own local copy alongside (relayerConfigForConnection). This keeps mixed +// embedded/standalone topologies on one Connection working: the process is +// started AND the folded copy is rendered, each writing its shared authority key +// into its own workspace with no collision. +func foldedOnlyAttestorIDs(spec Spec) map[AttestorID]struct{} { + connectionsByID := make(map[ConnectionID]ConnectionSpec, len(spec.Connections)) + for _, connection := range spec.Connections { + connectionsByID[connection.ID] = connection + } + embeddingClients := make(map[ClientID]struct{}) + standaloneClients := make(map[ClientID]struct{}) + for _, relayer := range spec.Relayers { + target := standaloneClients + if relayer.EmbedAttestors { + target = embeddingClients + } + for _, id := range relayer.Connections { + connection, ok := connectionsByID[id] + if !ok { + continue + } + target[clientIdentity(connection.A).ID] = struct{}{} + target[clientIdentity(connection.B).ID] = struct{}{} + } + } + foldedOnly := make(map[AttestorID]struct{}) + for _, attestor := range spec.Attestors { + _, embedded := embeddingClients[attestor.Client] + _, standalone := standaloneClients[attestor.Client] + if embedded && !standalone { + foldedOnly[attestor.ID] = struct{}{} + } + } + return foldedOnly +} + +func observedInstanceForClient( + connections []ConnectionSpec, + clientID ClientID, + instances map[IBCInstanceID]*IBCInstance, +) (*IBCInstance, error) { + for _, connection := range connections { + a := clientIdentity(connection.A) + b := clientIdentity(connection.B) + switch clientID { + case a.ID: + return instances[b.IBCInstance], nil + case b.ID: + return instances[a.IBCInstance], nil + } + } + return nil, fmt.Errorf("environment: no counterparty IBC Instance for Client %q", clientID) +} + +func acquireIBCInstance( + ctx context.Context, + declaration IBCInstanceSpec, + chain *Chain, + runtime Runtime, +) (*IBCInstance, error) { + setup, err := solidityIBCSetup(ctx, chain) + if err != nil { + return nil, err + } + switch instance := declaration.(type) { + case NewIBCInstance: + authority, err := runtime.evmAccount(instance.Authority) + if err != nil { + return nil, err + } + if fundingErr := ensureProtocolAuthorityFunded(ctx, chain, authority); fundingErr != nil { + return nil, fmt.Errorf("fund IBC Instance %q authority: %w", instance.ID, fundingErr) + } + resolved, err := setup.DeployInstance(ctx, authority) + if err != nil { + return nil, err + } + return &IBCInstance{ + id: instance.ID, + chain: chain, + locator: IBCInstanceLocator(resolved.Router.Hex()), + accessManager: EVMAddress(resolved.AccessManager.Hex()), + }, nil + case ExistingIBCInstance: + resolved, err := setup.AttachInstance(ctx, common.HexToAddress(string(instance.Locator))) + if err != nil { + return nil, err + } + return &IBCInstance{ + id: instance.ID, + chain: chain, + locator: IBCInstanceLocator(resolved.Router.Hex()), + accessManager: EVMAddress(resolved.AccessManager.Hex()), + }, nil + default: + return nil, fmt.Errorf("unsupported IBC Instance declaration %T", declaration) + } +} + +func prepareConnections( + ctx context.Context, + spec Spec, + dependencies connectionDependencies, + runtime Runtime, +) (connectionDependencies, error) { + dependencies.existingClients = make(map[ClientID]*IBCClient) + dependencies.preparedClients = make(map[ClientID]*solidityibc.PreparedClient) + + for _, connection := range spec.Connections { + ends := []struct { + label string + declaration ClientSpec + counterparty ClientSpec + }{ + {label: "A", declaration: connection.A, counterparty: connection.B}, + {label: "B", declaration: connection.B, counterparty: connection.A}, + } + locators := map[string]IBCClientLocator{ + "A": clientLocator(connection.ID, "A", connection.A), + "B": clientLocator(connection.ID, "B", connection.B), + } + for _, end := range ends { + identity := clientIdentity(end.declaration) + switch client := end.declaration.(type) { + case ExistingClient: + resolved, err := acquireIBCClient( + ctx, + end.label, + client, + locators, + dependencies, + runtime, + ) + if err != nil { + return dependencies, fmt.Errorf( + "prepare existing IBC Client %q: %w", + identity.ID, + err, + ) + } + dependencies.existingClients[identity.ID] = resolved + case NewClient: + instance := dependencies.instances[identity.IBCInstance] + setup, err := solidityIBCSetup(ctx, instance.chain) + if err != nil { + return dependencies, err + } + authority, _ := runtime.evmAccount(client.Authority) + counterparty := clientIdentity(end.counterparty) + header, err := evmHeader(ctx, dependencies.instances[counterparty.IBCInstance].chain) + if err != nil { + return dependencies, fmt.Errorf( + "prepare IBC Client %q counterparty header: %w", + identity.ID, + err, + ) + } + attestors := make([]common.Address, 0, len(dependencies.attestorSpecs[identity.ID])) + for _, declaration := range dependencies.attestorSpecs[identity.ID] { + account, _ := runtime.evmAccount(declaration.Authority) + attestors = append(attestors, account.Address()) + } + prepared, err := setup.PrepareClient( + ctx, + authority, + common.HexToAddress(string(instance.locator)), + solidityibc.AttestationClientConfig{ + ID: string(locators[end.label]), + CounterpartyClientID: string(locators[counterpartyEnd(end.label)]), + Attestors: attestors, + MinRequiredSignatures: client.MinRequiredSignatures, + InitialHeight: header.Number.Uint64(), + InitialTimestamp: header.Time, + }, + ) + if err != nil { + return dependencies, fmt.Errorf("prepare IBC Client %q: %w", identity.ID, err) + } + dependencies.preparedClients[identity.ID] = prepared + } + } + } + return dependencies, nil +} + +func acquireConnection( + ctx context.Context, + declaration ConnectionSpec, + dependencies connectionDependencies, + runtime Runtime, +) (*Connection, error) { + locators := map[string]IBCClientLocator{ + "A": clientLocator(declaration.ID, "A", declaration.A), + "B": clientLocator(declaration.ID, "B", declaration.B), + } + + a, err := acquireIBCClient( + ctx, "A", declaration.A, locators, dependencies, runtime, + ) + if err != nil { + return nil, err + } + b, err := acquireIBCClient( + ctx, "B", declaration.B, locators, dependencies, runtime, + ) + if err != nil { + return nil, err + } + if a.counterparty != b.locator || b.counterparty != a.locator { + return nil, fmt.Errorf("resolved IBC Clients are not reciprocal") + } + return &Connection{id: declaration.ID, a: a, b: b}, nil +} + +func acquireIBCClient( + ctx context.Context, + end string, + declaration ClientSpec, + locators map[string]IBCClientLocator, + dependencies connectionDependencies, + runtime Runtime, +) (*IBCClient, error) { + identity := clientIdentity(declaration) + instance := dependencies.instances[identity.IBCInstance] + counterpartyEnd := counterpartyEnd(end) + counterpartyLocator := locators[counterpartyEnd] + if resolved := dependencies.existingClients[identity.ID]; resolved != nil { + return resolved, nil + } + + var ( + resolved solidityibc.Client + err error + ) + switch client := declaration.(type) { + case ExistingClient: + setup, setupErr := solidityIBCSetup(ctx, instance.chain) + if setupErr != nil { + return nil, setupErr + } + resolved, err = setup.AttachClient( + ctx, + common.HexToAddress(string(instance.locator)), + string(client.Locator), + string(counterpartyLocator), + ) + if err != nil { + return nil, err + } + if attestorErr := requireDeclaredAttestors( + identity.ID, + resolved.Attestors, + dependencies.attestorSpecs[identity.ID], + runtime, + ); attestorErr != nil { + return nil, attestorErr + } + case NewClient: + prepared := dependencies.preparedClients[identity.ID] + if prepared == nil { + return nil, fmt.Errorf("IBC Client %q was not prepared", identity.ID) + } + authority, err := runtime.evmAccount(client.Authority) + if err != nil { + return nil, err + } + if fundingErr := ensureProtocolAuthorityFunded(ctx, instance.chain, authority); fundingErr != nil { + return nil, fmt.Errorf("fund IBC Client %q authority: %w", identity.ID, fundingErr) + } + resolved, err = prepared.Deploy(ctx) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported IBC Client declaration %T", declaration) + } + + attestors := make([]EVMAddress, len(resolved.Attestors)) + for i, address := range resolved.Attestors { + attestors[i] = EVMAddress(address.Hex()) + } + return &IBCClient{ + id: identity.ID, + instance: instance, + locator: IBCClientLocator(resolved.ID), + lightClient: EVMAddress(resolved.Address.Hex()), + counterparty: IBCClientLocator(resolved.CounterpartyClientID), + attestors: attestors, + minRequiredSignatures: resolved.MinRequiredSignatures, + }, nil +} + +func ensureProtocolAuthorityFunded(ctx context.Context, chain *Chain, authority evm.Account) error { + if chain == nil { + return fmt.Errorf("missing resolved Chain") + } + if chain.funding == nil { + return nil + } + funding, err := chain.Funding() + if err != nil { + return err + } + // Replenishing to the same minimum before each protocol mutation keeps the + // contract independent of how much gas earlier setup transactions consumed. + minimum := new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)) + return funding.EnsureEOABalance(ctx, authority.Address(), minimum) +} + +func counterpartyEnd(end string) string { + if end == "A" { + return "B" + } + return "A" +} + +func acquireAttestor( + ctx context.Context, + declaration AttestorSpec, + dependencies attestorDependencies, + runtime Runtime, + ws workspace, +) (attestorAcquisition, error) { + // authority is the Attestor's registered on-chain identity: it was registered + // on the light client during Connection setup and is carried into the + // Relayer's config as the attestor's evmAddress. signingAuthority is the key + // the process actually signs with; it equals authority unless the spec + // deliberately overrides it to forge a registered-vs-signing mismatch. + authority, err := runtime.evmAccount(declaration.Authority) + if err != nil { + return attestorAcquisition{}, err + } + signingAuthorityID := declaration.Authority + if declaration.SigningKeyAuthority != "" { + signingAuthorityID = declaration.SigningKeyAuthority + } + signingAccount, err := runtime.evmAccount(signingAuthorityID) + if err != nil { + return attestorAcquisition{}, err + } + // Dual mode: the Attestor is folded into its Relayer's process, so no separate + // `ibc attestor run` process is started. The signer key is still resolved here + // (it was registered on the light client during Connection setup) and carried + // into the Relayer's config by relayerConfigForConnection. + if dependencies.embedded { + return attestorAcquisition{ + attestor: &Attestor{ + id: declaration.ID, + client: dependencies.client, + observed: dependencies.observed, + signer: EVMAddress(authority.Address().Hex()), + }, + description: fmt.Sprintf("release embedded Attestor %q (no process)", declaration.ID), + release: func(context.Context) error { return nil }, + }, nil + } + process, err := ibclink.StartAttestor(ctx, ibclink.AttestorLaunch{ + BinaryPath: ibclink.ResolvedBin(), + WorkDir: filepath.Join(ws.privateDir, "attestor-"+resourcePathToken(string(declaration.ID))), + Name: string(declaration.ID), + ChainID: strconv.FormatUint(dependencies.observed.chain.evmChainID, 10), + PrivateKeyHex: runtime.Authorities[signingAuthorityID].PrivateKeyHex, + RPCURL: dependencies.observed.chain.rpcURL, + RouterAddress: string(dependencies.observed.locator), + }) + if err != nil { + if process != nil { + return attestorAcquisition{ + description: fmt.Sprintf("stop Attestor %q", declaration.ID), + release: process.Stop, + }, err + } + return attestorAcquisition{}, err + } + if process.SignerAddress() != signingAccount.Address() { + return attestorAcquisition{ + description: fmt.Sprintf("stop Attestor %q", declaration.ID), + release: process.Stop, + }, fmt.Errorf("Attestor %q process loaded a key that does not match its signing authority", declaration.ID) + } + return attestorAcquisition{ + attestor: &Attestor{ + id: declaration.ID, + client: dependencies.client, + observed: dependencies.observed, + // The registered identity, not the process key: with a signing-key + // override these differ, and the Relayer config must advertise the + // registered address so identity enforcement can reject the mismatch. + signer: EVMAddress(authority.Address().Hex()), + grpc: process.GRPCAddress(), + process: process, + }, + description: fmt.Sprintf("stop Attestor %q", declaration.ID), + release: process.Stop, + }, nil +} + +func solidityIBCSetup(ctx context.Context, chain *Chain) (*solidityibc.Setup, error) { + if chain == nil { + return nil, fmt.Errorf("missing resolved Chain") + } + var setup *solidityibc.Setup + ok, err := evm.WithChainClient(chain.impl, func(client *evm.EVMClient) error { + var setupErr error + setup, setupErr = solidityibc.NewSetup(ctx, client.Client()) + return setupErr + }) + if !ok { + return nil, fmt.Errorf("Chain %q has no EVM client", chain.id) + } + return setup, err +} + +func evmHeader(ctx context.Context, chain *Chain) (*types.Header, error) { + var header *types.Header + ok, err := evm.WithChainClient(chain.impl, func(client *evm.EVMClient) error { + var headerErr error + header, headerErr = client.Client().HeaderByNumber(ctx, nil) + return headerErr + }) + if !ok { + return nil, fmt.Errorf("Chain %q has no EVM client", chain.id) + } + return header, err +} + +func clientLocator(connectionID ConnectionID, end string, declaration ClientSpec) IBCClientLocator { + if existing, ok := declaration.(ExistingClient); ok { + return existing.Locator + } + client := clientIdentity(declaration) + hash := sha256.Sum256( + []byte(string(connectionID) + "\x00" + end + "\x00" + string(client.ID) + "\x00" + string(client.IBCInstance)), + ) + return IBCClientLocator("link-" + hex.EncodeToString(hash[:])) +} + +func requireDeclaredAttestors( + clientID ClientID, + onChain []common.Address, + declarations []AttestorSpec, + runtime Runtime, +) error { + registered := make(map[common.Address]struct{}, len(onChain)) + for _, address := range onChain { + registered[address] = struct{}{} + } + for _, declaration := range declarations { + account, _ := runtime.evmAccount(declaration.Authority) + if _, ok := registered[account.Address()]; !ok { + return fmt.Errorf( + "Attestor %q signer %s is not registered for existing IBC Client %q", + declaration.ID, + account.Address(), + clientID, + ) + } + } + return nil +} + +type relayerDependencies struct { + connections []*Connection + attestorSpecs map[ClientID][]AttestorSpec + attestors map[AttestorID]*Attestor +} + +// relayerConnectionIDs returns the Connections one Relayer serves, in declaration +// order. +func relayerConnectionIDs(declaration RelayerSpec) []ConnectionID { + return slices.Clone(declaration.Connections) +} + +func acquireRelayers( + ctx context.Context, + spec Spec, + connections map[ConnectionID]*Connection, + attestors map[AttestorID]*Attestor, + runtime Runtime, + ws workspace, + d drivers, + effects *effectJournal, +) (map[RelayerID]*Relayer, error) { + if d.acquireRelayer == nil { + return map[RelayerID]*Relayer{}, nil + } + attestorSpecsByClient := make(map[ClientID][]AttestorSpec) + for _, declaration := range spec.Attestors { + attestorSpecsByClient[declaration.Client] = append(attestorSpecsByClient[declaration.Client], declaration) + } + for client, declarations := range attestorSpecsByClient { + attestorSpecsByClient[client] = sortedAttestorSpecs(declarations) + } + + relayers := make(map[RelayerID]*Relayer, len(spec.Relayers)) + for _, declaration := range sortedRelayerSpecs(spec.Relayers) { + served := make([]*Connection, 0, len(declaration.Connections)) + for _, id := range relayerConnectionIDs(declaration) { + served = append(served, connections[id]) + } + acquisition, err := d.acquireRelayer(ctx, declaration, relayerDependencies{ + connections: served, + attestorSpecs: attestorSpecsByClient, + attestors: attestors, + }, runtime, ws) + if err != nil { + if acquisition.release != nil { + effects.append(cleanupEffect{ + description: acquisition.description, + release: acquisition.release, + }) + } + return nil, fmt.Errorf("start Relayer %q failed: %w", declaration.ID, err) + } + if acquisition.relayer == nil || acquisition.release == nil { + return nil, fmt.Errorf("environment: Relayer %q adapter returned an incomplete acquisition", declaration.ID) + } + effects.append(cleanupEffect{ + description: acquisition.description, + release: acquisition.release, + }) + relayers[declaration.ID] = acquisition.relayer + } + return relayers, nil +} + +func acquireRelayer( + ctx context.Context, + declaration RelayerSpec, + dependencies relayerDependencies, + runtime Runtime, + ws workspace, +) (relayerAcquisition, error) { + if len(dependencies.connections) == 0 { + return relayerAcquisition{}, fmt.Errorf("Relayer %q references no IBC Connection", declaration.ID) + } + for _, connection := range dependencies.connections { + if connection == nil || connection.a == nil || connection.b == nil { + return relayerAcquisition{}, fmt.Errorf( + "Relayer %q references an unresolved IBC Connection", declaration.ID, + ) + } + } + authority, err := runtime.evmAccount(declaration.Authority) + if err != nil { + return relayerAcquisition{}, err + } + + config, err := relayerConfigForConnections(dependencies.connections, dependencies, declaration, runtime) + if err != nil { + return relayerAcquisition{}, fmt.Errorf("Relayer %q config: %w", declaration.ID, err) + } + + process, err := managedrelayer.Start(ctx, managedrelayer.Spec{ + BinaryPath: ibclink.ResolvedBin(), + WorkDir: filepath.Join(ws.privateDir, "relayer-"+resourcePathToken(string(declaration.ID))), + RelaySignerKeyHex: runtime.Authorities[declaration.Authority].PrivateKeyHex, + Config: config, + StartupTimeout: relayerStartupTimeout(dependencies.connections), + }) + if err != nil { + if process != nil { + return relayerAcquisition{ + description: fmt.Sprintf("stop Relayer %q", declaration.ID), + release: process.Stop, + }, err + } + return relayerAcquisition{}, err + } + if process.SignerAddress() != authority.Address() { + return relayerAcquisition{ + description: fmt.Sprintf("stop Relayer %q", declaration.ID), + release: process.Stop, + }, fmt.Errorf("Relayer %q signer address does not match its runtime authority", declaration.ID) + } + return relayerAcquisition{ + relayer: &Relayer{ + id: declaration.ID, + connections: dependencies.connections, + signer: EVMAddress(process.SignerAddress().Hex()), + process: process, + }, + description: fmt.Sprintf("stop Relayer %q", declaration.ID), + release: process.Stop, + }, nil +} + +// relayerConfigForConnections derives the `ibc relayer run` configuration from +// the resolved Connection graph. Each Client end of each served Connection +// becomes a source client whose attestorSet is the Attestors bound to that +// Client (which attest its counterparty Chain), and every Chain is listed once +// across all served Connections (they must agree on the ICS26 router per Chain). +func relayerConfigForConnections( + connections []*Connection, + dependencies relayerDependencies, + declaration RelayerSpec, + runtime Runtime, +) (managedrelayer.Config, error) { + type clientEnd struct { + client *IBCClient + counterparty *IBCClient + } + ends := make([]clientEnd, 0, 2*len(connections)) + for _, connection := range connections { + ends = append(ends, + clientEnd{client: connection.a, counterparty: connection.b}, + clientEnd{client: connection.b, counterparty: connection.a}, + ) + } + + chainsByID := make(map[string]managedrelayer.ChainParams) + chainOrder := make([]string, 0, 2) + clients := make([]managedrelayer.ClientParams, 0, len(ends)) + routes := make([]managedrelayer.RouteParams, 0, len(ends)) + // Dual mode: each embedded Attestor also contributes a top-level attestation + // block folded into this relayer process. + var attestations []managedrelayer.AttestationParams + for _, end := range ends { + hostChain := end.client.instance.chain + counterpartyChain := end.counterparty.instance.chain + hostChainID := strconv.FormatUint(hostChain.evmChainID, 10) + router := string(end.client.instance.locator) + if existing, ok := chainsByID[hostChainID]; ok { + if existing.ICS26Router != router { + return managedrelayer.Config{}, fmt.Errorf( + "chain %s hosts conflicting ICS26 routers %s and %s", hostChainID, existing.ICS26Router, router, + ) + } + } else { + chainsByID[hostChainID] = managedrelayer.ChainParams{ + ChainID: hostChainID, + RPC: hostChain.rpcURL, + ICS26Router: router, + } + chainOrder = append(chainOrder, hostChainID) + } + + attestors := make([]managedrelayer.AttestorRef, 0, len(dependencies.attestorSpecs[end.client.id])) + for _, attestorSpec := range dependencies.attestorSpecs[end.client.id] { + resolved := dependencies.attestors[attestorSpec.ID] + if resolved == nil { + return managedrelayer.Config{}, fmt.Errorf( + "IBC Client %q Attestor %q is not resolved", end.client.id, attestorSpec.ID, + ) + } + if declaration.EmbedAttestors { + // Fold the Attestor in-process: a local ref (no gRPC, inferred from the + // matching folded attestation) plus the top-level attestation observing + // the Client's counterparty chain. The Attestor's authority resolved + // during acquisition, so its signer key is present here. + observed := resolved.observed + if observed == nil || observed.chain == nil { + return managedrelayer.Config{}, fmt.Errorf( + "embedded Attestor %q has no observed chain", attestorSpec.ID, + ) + } + name := string(attestorSpec.ID) + attestors = append(attestors, managedrelayer.AttestorRef{Name: name}) + attestations = append(attestations, managedrelayer.AttestationParams{ + ChainID: strconv.FormatUint(observed.chain.evmChainID, 10), + Name: name, + PrivateKeyHex: runtime.Authorities[attestorSpec.Authority].PrivateKeyHex, + RouterAddress: string(observed.locator), + FinalityOffset: relayerFinalityOffset, + }) + continue + } + attestors = append(attestors, managedrelayer.AttestorRef{ + Name: string(attestorSpec.ID), + GRPC: resolved.grpc, + EVMAddress: string(resolved.signer), + }) + } + if len(attestors) == 0 { + return managedrelayer.Config{}, fmt.Errorf("IBC Client %q has no Attestor to relay", end.client.id) + } + if end.client.minRequiredSignatures == 0 || int(end.client.minRequiredSignatures) > len(attestors) { + return managedrelayer.Config{}, fmt.Errorf( + "IBC Client %q threshold %d is out of range for %d Attestors", + end.client.id, end.client.minRequiredSignatures, len(attestors), + ) + } + + clients = append(clients, managedrelayer.ClientParams{ + Alias: string(end.client.id), + ClientID: string(end.client.locator), + ChainID: hostChainID, + CounterpartyChainID: strconv.FormatUint(counterpartyChain.evmChainID, 10), + CounterpartyClientID: string(end.client.counterparty), + Threshold: end.client.minRequiredSignatures, + FinalityOffset: relayerFinalityOffset, + Attestors: attestors, + }) + routes = append(routes, managedrelayer.RouteParams{ + SourceClient: string(end.client.id), + AutoRelay: declaration.AutoRelay, + Lookback: declaration.Lookback, + }) + } + + chains := make([]managedrelayer.ChainParams, 0, len(chainOrder)) + for _, id := range chainOrder { + chains = append(chains, chainsByID[id]) + } + return managedrelayer.Config{ + Chains: chains, + Clients: clients, + Routes: routes, + Attestations: attestations, + PostgresURL: declaration.PostgresURL, + }, nil +} + +func relayerStartupTimeout(connections []*Connection) time.Duration { + var budget time.Duration + for _, connection := range connections { + if b := connection.a.instance.chain.timing.CompletionBudget; b > budget { + budget = b + } + if b := connection.b.instance.chain.timing.CompletionBudget; b > budget { + budget = b + } + } + return budget +} + +func sortedRelayerSpecs(in []RelayerSpec) []RelayerSpec { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b RelayerSpec) int { return cmp.Compare(string(a.ID), string(b.ID)) }) + return out +} + +func sortedAttestorSpecs(in []AttestorSpec) []AttestorSpec { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b AttestorSpec) int { return cmp.Compare(string(a.ID), string(b.ID)) }) + return out +} + +func sortedIBCInstanceSpecs(in []IBCInstanceSpec) []IBCInstanceSpec { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b IBCInstanceSpec) int { + return cmp.Compare(string(a.ibcInstanceID()), string(b.ibcInstanceID())) + }) + return out +} + +func sortedConnectionSpecs(in []ConnectionSpec) []ConnectionSpec { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b ConnectionSpec) int { + return cmp.Compare(string(a.ID), string(b.ID)) + }) + return out +} diff --git a/e2e/internal/harness/environment/relayer_start_test.go b/e2e/internal/harness/environment/relayer_start_test.go new file mode 100644 index 000000000..aff602114 --- /dev/null +++ b/e2e/internal/harness/environment/relayer_start_test.go @@ -0,0 +1,317 @@ +package environment + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + managedrelayer "github.com/cosmos/ibc/e2e/internal/harness/relayerproc" +) + +func TestRelayerConfigForConnectionOrientsAttestorSets(t *testing.T) { + chainA := &Chain{id: "chain-a", evmChainID: 31337, rpcURL: "http://a", timing: instantTiming()} + chainB := &Chain{id: "chain-b", evmChainID: 31338, rpcURL: "http://b", timing: instantTiming()} + instanceA := &IBCInstance{id: "ibc-a", chain: chainA, locator: "0xrouterA"} + instanceB := &IBCInstance{id: "ibc-b", chain: chainB, locator: "0xrouterB"} + clientA := &IBCClient{ + id: "client-a", instance: instanceA, locator: "clientA-onchain", + counterparty: "clientB-onchain", minRequiredSignatures: 1, + } + clientB := &IBCClient{ + id: "client-b", instance: instanceB, locator: "clientB-onchain", + counterparty: "clientA-onchain", minRequiredSignatures: 1, + } + connection := &Connection{id: "connection-ab", a: clientA, b: clientB} + dependencies := relayerDependencies{ + connections: []*Connection{connection}, + attestorSpecs: map[ClientID][]AttestorSpec{ + "client-a": {{ID: "attestor-a", Client: "client-a"}}, + "client-b": {{ID: "attestor-b", Client: "client-b"}}, + }, + attestors: map[AttestorID]*Attestor{ + "attestor-a": {id: "attestor-a", grpc: "127.0.0.1:1111"}, + "attestor-b": {id: "attestor-b", grpc: "127.0.0.1:2222"}, + }, + } + + config, err := relayerConfigForConnections([]*Connection{connection}, dependencies, RelayerSpec{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, + }, Runtime{}) + require.NoError(t, err) + + require.Len(t, config.Chains, 2) + require.Len(t, config.Clients, 2) + require.Len(t, config.Routes, 2) + + a := clientByAlias(t, config, "client-a") + require.Equal(t, "clientA-onchain", a.ClientID) + require.Equal(t, "31337", a.ChainID) + require.Equal(t, "31338", a.CounterpartyChainID) + require.Equal(t, "clientB-onchain", a.CounterpartyClientID) + require.Equal(t, uint8(1), a.Threshold) + require.Equal(t, relayerFinalityOffset, a.FinalityOffset) + // The attestor bound to client-a (on chain-a) attests chain-a's counterparty + // chain-b; it belongs to client-a's config entry. + require.Len(t, a.Attestors, 1) + require.Equal(t, "attestor-a", a.Attestors[0].Name) + require.Equal(t, "127.0.0.1:1111", a.Attestors[0].GRPC) + + b := clientByAlias(t, config, "client-b") + require.Equal(t, "31338", b.ChainID) + require.Equal(t, "31337", b.CounterpartyChainID) + require.Equal(t, "attestor-b", b.Attestors[0].Name) + require.Equal(t, "127.0.0.1:2222", b.Attestors[0].GRPC) +} + +func TestRelayerConfigForConnectionRejectsThresholdAboveAttestors(t *testing.T) { + chainA := &Chain{id: "chain-a", evmChainID: 1, rpcURL: "http://a", timing: instantTiming()} + chainB := &Chain{id: "chain-b", evmChainID: 2, rpcURL: "http://b", timing: instantTiming()} + clientA := &IBCClient{ + id: "client-a", instance: &IBCInstance{chain: chainA, locator: "0x01"}, + locator: "a", counterparty: "b", minRequiredSignatures: 2, + } + clientB := &IBCClient{ + id: "client-b", instance: &IBCInstance{chain: chainB, locator: "0x02"}, + locator: "b", counterparty: "a", minRequiredSignatures: 1, + } + connection := &Connection{id: "connection-ab", a: clientA, b: clientB} + dependencies := relayerDependencies{ + connections: []*Connection{connection}, + attestorSpecs: map[ClientID][]AttestorSpec{ + "client-a": {{ID: "attestor-a"}}, + "client-b": {{ID: "attestor-b"}}, + }, + attestors: map[AttestorID]*Attestor{ + "attestor-a": {id: "attestor-a", grpc: "127.0.0.1:1"}, + "attestor-b": {id: "attestor-b", grpc: "127.0.0.1:2"}, + }, + } + _, err := relayerConfigForConnections( + []*Connection{connection}, dependencies, RelayerSpec{ID: "relayer-1"}, Runtime{}, + ) + require.ErrorContains(t, err, `IBC Client "client-a" threshold 2 is out of range for 1 Attestors`) +} + +func TestStartRealizesRelayerAndTearsDownBeforeAttestorsAndChains(t *testing.T) { + spec := mixedProtocolSpec() + spec.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-signer", + }} + runtime := mixedProtocolRuntime() + runtime.Authorities["relay-signer"] = EVMAuthority{PrivateKeyHex: testPrimaryPrivateKeyHex} + relaySigner, err := runtime.evmAccount("relay-signer") + require.NoError(t, err) + attestorAccount, err := runtime.evmAccount("attestor-signer") + require.NoError(t, err) + + var ( + releaseMu sync.Mutex + releases []string + ) + record := func(resource string) { + releaseMu.Lock() + defer releaseMu.Unlock() + releases = append(releases, resource) + } + + env, err := start(t.Context(), spec, runtime, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + id := declaration.chainID() + return fakeAcquisition(id, func(context.Context) error { + record("chain:" + string(id)) + return nil + }), nil + }, + acquireIBCInstance: func(_ context.Context, declaration IBCInstanceSpec, chain *Chain, _ Runtime) (*IBCInstance, error) { + return &IBCInstance{id: declaration.ibcInstanceID(), chain: chain, locator: "0xrouter"}, nil + }, + acquireConnection: func(_ context.Context, declaration ConnectionSpec, dependencies connectionDependencies, _ Runtime) (*Connection, error) { + return resolvedMixedConnection( + declaration.ID, + dependencies.instances, + EVMAddress(attestorAccount.Address().Hex()), + ), nil + }, + acquireAttestor: func(_ context.Context, declaration AttestorSpec, dependencies attestorDependencies, _ Runtime, _ workspace) (attestorAcquisition, error) { + return attestorAcquisition{ + attestor: &Attestor{ + id: declaration.ID, client: dependencies.client, observed: dependencies.observed, + signer: EVMAddress(attestorAccount.Address().Hex()), grpc: "127.0.0.1:9", + }, + description: "stop Attestor " + string(declaration.ID), + release: func(context.Context) error { + record("attestor:" + string(declaration.ID)) + return nil + }, + }, nil + }, + acquireRelayer: func(_ context.Context, declaration RelayerSpec, dependencies relayerDependencies, _ Runtime, _ workspace) (relayerAcquisition, error) { + require.NotNil(t, dependencies.connections[0]) + require.Equal(t, ConnectionID("connection-ab"), dependencies.connections[0].ID()) + require.Contains(t, dependencies.attestors, AttestorID("attestor-a")) + return relayerAcquisition{ + relayer: &Relayer{ + id: declaration.ID, connections: dependencies.connections, + signer: EVMAddress(relaySigner.Address().Hex()), + }, + description: "stop Relayer " + string(declaration.ID), + release: func(context.Context) error { + record("relayer:" + string(declaration.ID)) + return nil + }, + }, nil + }, + }) + require.NoError(t, err) + + relayer, err := env.Relayer("relayer-1") + require.NoError(t, err) + require.Equal(t, []ConnectionID{"connection-ab"}, relayer.ConnectionIDs()) + require.Equal(t, EVMAddress(relaySigner.Address().Hex()), relayer.SignerAddress()) + + _, err = env.Relayer("missing") + require.ErrorContains(t, err, `no Relayer "missing"`) + + require.NoError(t, env.Close(t.Context())) + releaseMu.Lock() + got := append([]string(nil), releases...) + releaseMu.Unlock() + require.Equal(t, "relayer:relayer-1", got[0], "relayers stop first") + require.Equal(t, "attestor:attestor-a", got[1], "then attestors") + require.ElementsMatch(t, []string{"chain:chain-a", "chain:chain-b"}, got[2:], "then chains") +} + +// TestFoldedOnlyAttestorIDsSuppressesPerNeed proves the standalone-attestor +// suppression is decided per-need: an Attestor is folded (no process) only when +// every Relayer referencing it embeds it. A mixed topology with one embedding +// and one non-embedding Relayer on the same Connection must still start the +// standalone processes, otherwise the non-embedding Relayer renders an empty +// attestor gRPC endpoint and fails. +func TestFoldedOnlyAttestorIDsSuppressesPerNeed(t *testing.T) { + connections := []ConnectionSpec{{ + ID: "connection-ab", + A: NewClient{ID: "client-a", IBCInstance: "ibc-a", Authority: "owner", MinRequiredSignatures: 1}, + B: NewClient{ID: "client-b", IBCInstance: "ibc-b", Authority: "owner", MinRequiredSignatures: 1}, + }} + attestors := []AttestorSpec{ + {ID: "attestor-a", Client: "client-a", Authority: "signer-a"}, + {ID: "attestor-b", Client: "client-b", Authority: "signer-b"}, + } + embed := RelayerSpec{ + ID: "relayer-embed", Connections: []ConnectionID{"connection-ab"}, Authority: "r1", EmbedAttestors: true, + } + standalone := RelayerSpec{ + ID: "relayer-standalone", Connections: []ConnectionID{"connection-ab"}, Authority: "r2", + } + + cases := []struct { + name string + relayers []RelayerSpec + want []AttestorID + }{ + {name: "no relayer starts every process", relayers: nil, want: nil}, + {name: "standalone only starts every process", relayers: []RelayerSpec{standalone}, want: nil}, + { + name: "pure embed folds every attestor", + relayers: []RelayerSpec{embed}, + want: []AttestorID{"attestor-a", "attestor-b"}, + }, + { + name: "mixed embed and standalone starts every process", + relayers: []RelayerSpec{embed, standalone}, + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + folded := foldedOnlyAttestorIDs(Spec{ + Connections: connections, + Attestors: attestors, + Relayers: tc.relayers, + }) + got := make([]AttestorID, 0, len(folded)) + for id := range folded { + got = append(got, id) + } + require.ElementsMatch(t, tc.want, got) + }) + } +} + +// TestRelayerConfigForConnectionMixedTopology proves that, once the shared +// Attestor processes are started (they carry a gRPC endpoint), both Relayers on +// one Connection render valid configs: the embedding Relayer folds local +// in-process attestors with top-level attestations, while the non-embedding +// Relayer references the same Attestors over gRPC. The gRPC endpoints must be +// present for the standalone Relayer — the bug this guards left them empty. +func TestRelayerConfigForConnectionMixedTopology(t *testing.T) { + chainA := &Chain{id: "chain-a", evmChainID: 31337, rpcURL: "http://a", timing: instantTiming()} + chainB := &Chain{id: "chain-b", evmChainID: 31338, rpcURL: "http://b", timing: instantTiming()} + instanceA := &IBCInstance{id: "ibc-a", chain: chainA, locator: "0xrouterA"} + instanceB := &IBCInstance{id: "ibc-b", chain: chainB, locator: "0xrouterB"} + clientA := &IBCClient{ + id: "client-a", instance: instanceA, locator: "clientA-onchain", + counterparty: "clientB-onchain", minRequiredSignatures: 1, + } + clientB := &IBCClient{ + id: "client-b", instance: instanceB, locator: "clientB-onchain", + counterparty: "clientA-onchain", minRequiredSignatures: 1, + } + connection := &Connection{id: "connection-ab", a: clientA, b: clientB} + // The Attestors were started as standalone processes (mixed topology needs + // them), so each carries a resolved observed chain and a gRPC endpoint. + dependencies := relayerDependencies{ + connections: []*Connection{connection}, + attestorSpecs: map[ClientID][]AttestorSpec{ + "client-a": {{ID: "attestor-a", Client: "client-a", Authority: "signer-a"}}, + "client-b": {{ID: "attestor-b", Client: "client-b", Authority: "signer-b"}}, + }, + attestors: map[AttestorID]*Attestor{ + "attestor-a": {id: "attestor-a", observed: instanceB, grpc: "127.0.0.1:1111"}, + "attestor-b": {id: "attestor-b", observed: instanceA, grpc: "127.0.0.1:2222"}, + }, + } + runtime := Runtime{Authorities: map[AuthorityID]EVMAuthority{ + "signer-a": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + "signer-b": {PrivateKeyHex: testSecondaryPrivateKeyHex}, + }} + + standalone, err := relayerConfigForConnections([]*Connection{connection}, dependencies, RelayerSpec{ + ID: "relayer-standalone", Connections: []ConnectionID{"connection-ab"}, + }, runtime) + require.NoError(t, err) + require.Empty(t, standalone.Attestations, "a non-embedding relayer folds no attestations") + for _, alias := range []string{"client-a", "client-b"} { + client := clientByAlias(t, standalone, alias) + require.Len(t, client.Attestors, 1) + require.NotEmpty(t, client.Attestors[0].GRPC, + "standalone relayer must reach attestor %q over gRPC", client.Attestors[0].Name) + } + + embedded, err := relayerConfigForConnections([]*Connection{connection}, dependencies, RelayerSpec{ + ID: "relayer-embed", Connections: []ConnectionID{"connection-ab"}, EmbedAttestors: true, + }, runtime) + require.NoError(t, err) + // Both attestors are folded in-process: a top-level attestation each, and the + // client refs carry no gRPC endpoint (renderConfig infers locality from the + // matching folded attestation name). + require.Len(t, embedded.Attestations, 2, "embedding relayer folds both attestors in-process") + for _, alias := range []string{"client-a", "client-b"} { + client := clientByAlias(t, embedded, alias) + require.Len(t, client.Attestors, 1) + require.Empty(t, client.Attestors[0].GRPC, "embedded attestor ref carries no gRPC endpoint") + } +} + +func clientByAlias(t *testing.T, config managedrelayer.Config, alias string) managedrelayer.ClientParams { + t.Helper() + for _, client := range config.Clients { + if client.Alias == alias { + return client + } + } + t.Fatalf("no client %q in config", alias) + return managedrelayer.ClientParams{} +} diff --git a/e2e/internal/harness/environment/runtime.go b/e2e/internal/harness/environment/runtime.go new file mode 100644 index 000000000..b00d0ef68 --- /dev/null +++ b/e2e/internal/harness/environment/runtime.go @@ -0,0 +1,57 @@ +package environment + +import ( + "fmt" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" +) + +// Runtime contains process-local bindings that are separate from the durable Spec. +type Runtime struct { + Endpoints map[EndpointBindingID]EndpointBinding + Authorities map[AuthorityID]EVMAuthority +} + +type EndpointBinding struct { + RPCURL string +} + +type EVMAuthority struct { + PrivateKeyHex string +} + +func (r Runtime) endpoint(id EndpointBindingID) (EndpointBinding, bool) { + b, ok := r.Endpoints[id] + return b, ok +} + +func (r Runtime) authority(id AuthorityID) (EVMAuthority, bool) { + b, ok := r.Authorities[id] + return b, ok +} + +func (r Runtime) evmAccount(id AuthorityID) (evm.Account, error) { + binding, ok := r.authority(id) + if !ok || binding.PrivateKeyHex == "" { + return evm.Account{}, fmt.Errorf("environment: no runtime authority binding for %q", id) + } + account, err := evm.AccountFromHex(binding.PrivateKeyHex) + if err != nil { + return evm.Account{}, fmt.Errorf("environment: invalid runtime authority binding for %q: %w", id, err) + } + return account, nil +} + +func (r Runtime) snapshot() Runtime { + snapshot := Runtime{ + Endpoints: make(map[EndpointBindingID]EndpointBinding, len(r.Endpoints)), + Authorities: make(map[AuthorityID]EVMAuthority, len(r.Authorities)), + } + for id, binding := range r.Endpoints { + snapshot.Endpoints[id] = binding + } + for id, binding := range r.Authorities { + snapshot.Authorities[id] = binding + } + return snapshot +} diff --git a/e2e/internal/harness/environment/solidityibc/accessmanager/contract.go b/e2e/internal/harness/environment/solidityibc/accessmanager/contract.go new file mode 100644 index 000000000..fd4ddce02 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/accessmanager/contract.go @@ -0,0 +1,2903 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package accessmanager + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// AccessManagerMetaData contains all meta data concerning the AccessManager contract. +var AccessManagerMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"initialAdmin\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"ADMIN_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"PUBLIC_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canCall\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"selector\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"immediate\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"delay\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"cancel\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"consumeScheduledOp\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"execute\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"expiration\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccess\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"since\",\"type\":\"uint48\",\"internalType\":\"uint48\"},{\"name\":\"currentDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"effect\",\"type\":\"uint48\",\"internalType\":\"uint48\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNonce\",\"inputs\":[{\"name\":\"id\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRoleAdmin\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRoleGrantDelay\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRoleGuardian\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSchedule\",\"inputs\":[{\"name\":\"id\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint48\",\"internalType\":\"uint48\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTargetAdminDelay\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTargetFunctionRole\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"selector\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"grantRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"executionDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"hasRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"isMember\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"executionDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"hashOperation\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isTargetClosed\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"labelRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"label\",\"type\":\"string\",\"internalType\":\"string\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"minSetback\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"multicall\",\"inputs\":[{\"name\":\"data\",\"type\":\"bytes[]\",\"internalType\":\"bytes[]\"}],\"outputs\":[{\"name\":\"results\",\"type\":\"bytes[]\",\"internalType\":\"bytes[]\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"callerConfirmation\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"revokeRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"schedule\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"when\",\"type\":\"uint48\",\"internalType\":\"uint48\"}],\"outputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setGrantDelay\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"newDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setRoleAdmin\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"admin\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setRoleGuardian\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"guardian\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setTargetAdminDelay\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newDelay\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setTargetClosed\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"closed\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setTargetFunctionRole\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"selectors\",\"type\":\"bytes4[]\",\"internalType\":\"bytes4[]\"},{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"updateAuthority\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newAuthority\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"OperationCanceled\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint32\",\"indexed\":true,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OperationExecuted\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint32\",\"indexed\":true,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OperationScheduled\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"nonce\",\"type\":\"uint32\",\"indexed\":true,\"internalType\":\"uint32\"},{\"name\":\"schedule\",\"type\":\"uint48\",\"indexed\":false,\"internalType\":\"uint48\"},{\"name\":\"caller\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleAdminChanged\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"admin\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleGrantDelayChanged\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"delay\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"},{\"name\":\"since\",\"type\":\"uint48\",\"indexed\":false,\"internalType\":\"uint48\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleGranted\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"delay\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"},{\"name\":\"since\",\"type\":\"uint48\",\"indexed\":false,\"internalType\":\"uint48\"},{\"name\":\"newMember\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleGuardianChanged\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"guardian\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleLabel\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"label\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleRevoked\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TargetAdminDelayUpdated\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"delay\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"},{\"name\":\"since\",\"type\":\"uint48\",\"indexed\":false,\"internalType\":\"uint48\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TargetClosed\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"closed\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TargetFunctionRoleUpdated\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"selector\",\"type\":\"bytes4\",\"indexed\":false,\"internalType\":\"bytes4\"},{\"name\":\"roleId\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccessManagerAlreadyScheduled\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"AccessManagerBadConfirmation\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccessManagerExpired\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"AccessManagerInvalidInitialAdmin\",\"inputs\":[{\"name\":\"initialAdmin\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"AccessManagerLockedRole\",\"inputs\":[{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccessManagerNotReady\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"AccessManagerNotScheduled\",\"inputs\":[{\"name\":\"operationId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"AccessManagerUnauthorizedAccount\",\"inputs\":[{\"name\":\"msgsender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"roleId\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccessManagerUnauthorizedCall\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"selector\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}]},{\"type\":\"error\",\"name\":\"AccessManagerUnauthorizedCancel\",\"inputs\":[{\"name\":\"msgsender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"selector\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}]},{\"type\":\"error\",\"name\":\"AccessManagerUnauthorizedConsume\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"AddressEmptyCode\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"FailedCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBalance\",\"inputs\":[{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"needed\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"SafeCastOverflowedUintDowncast\",\"inputs\":[{\"name\":\"bits\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", + Bin: "0x6080604052346102b757604051601f6130be38819003918201601f19168301916001600160401b0383118484101761015a578084926020946040528339810103126102b757516001600160a01b038116908190036102b75780156102a4575f8181525f51602061307e5f395f51905f52602052604090205465ffffffffffff161580156101825765ffffffffffff610096426102bb565b1665ffffffffffff811161016e578060405191604083019383851060018060401b0386111761015a5760409485529183525f60208085018281528783525f51602061307e5f395f51905f529091529481209351845495516001600160a01b031990961665ffffffffffff919091161760309590951b600160301b600160a01b0316949094179092555f51602061309e5f395f51905f52916060915b65ffffffffffff604051928684521660208301526040820152a3604051612d9390816102eb8239f35b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b5f8281525f51602061307e5f395f51905f526020526040902054906101a6426102bb565b63ffffffff8360301c169265ffffffffffff808260701c1692168211155f146102925750505b63ffffffff8216801561028b5763ffffffff811161016e575b65ffffffffffff63ffffffff6101fa426102bb565b921691160165ffffffffffff811161016e575f8481525f51602061307e5f395f51905f52602090815260408083208054600160301b600160a01b0319169185901b6dffffffffffff0000000000000000169690921b67ffffffff00000000169590951760301b600160301b600160a01b0316949094179093555f51602061309e5f395f51905f529160609190610131565b505f6101e5565b63ffffffff9060501c169250506101cc565b630409d6d160e11b5f525f60045260245ffd5b5f80fd5b65ffffffffffff81116102d35765ffffffffffff1690565b6306dfcc6560e41b5f52603060045260245260445ffdfe60806040526004361015610011575f80fd5b5f5f3560e01c806308d6122d14611a305780630b0a93ba146119e957806312be8727146119c6578063167bd3951461190957806318ff183c146118485780631cff79cd1461169757806325c471a01461128c5780633078f1141461123157806330cae187146111715780633adc277a146111425780633ca7c02a1461111f5780634136a33c146110ec5780634665096d146110ce5780634c1da1e21461109c5780635296295214610fc8578063530dd45614610f715780636d5115bd14610eeb57806375b238fc14610ecf578063853551b814610dfa57806394c7d7ee14610c98578063a166aa8914610c42578063a64d95ce14610afb578063abd9bd2a14610ad6578063ac9650d8146108eb578063b70096131461088e578063b7d2b1621461085b578063cc1b6c811461083d578063d1f856ee146107f8578063d22b5989146106f5578063d6bb62c6146104a7578063f801a698146101f25763fe0776f51461017a575f80fd5b346101ef5760406003193601126101ef57610193611bc7565b61019b611b73565b903373ffffffffffffffffffffffffffffffffffffffff8316036101c757906101c3916124da565b5080f35b6004837f5f159e63000000000000000000000000000000000000000000000000000000008152fd5b80fd5b50346101ef5760606003193601126101ef5761020c611b50565b9060243567ffffffffffffffff81116104a35761022d903690600401611bf5565b919060443565ffffffffffff811680910361049f5761024e848387336121a5565b905061026a63ffffffff61026142612d3e565b921680926120a1565b90158015610484575b61040e579065ffffffffffff809216908180821191180218169061029984828733611e6a565b93848452600260205265ffffffffffff60408520541680151590816103fd575b506103d15760409584936103c287947f82a2da5dee54ea8021c6545b4444620291e07ee83be6dd57edb175062715f3b494868b99526002602052600163ffffffff8a8a205460301c160163ffffffff81169989898c9b52600260205281812065ffffffffffff88167fffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000825416179055898152600260205220907fffffffffffffffffffffffffffffffffffffffffffff00000000ffffffffffff69ffffffff00000000000083549260301b16911617905573ffffffffffffffffffffffffffffffffffffffff8b519586958652336020870152168b850152608060608501526080840191611e4a565b0390a382519182526020820152f35b602484867f813e9459000000000000000000000000000000000000000000000000000000008252600452fd5b6104079150612484565b155f6102b9565b6064847fffffffff000000000000000000000000000000000000000000000000000000008873ffffffffffffffffffffffffffffffffffffffff6104528a896121f9565b917f81c6f24b000000000000000000000000000000000000000000000000000000008552336004521660245216604452fd5b508115158015610273575065ffffffffffff81168210610273565b8280fd5b5080fd5b50346101ef576104cf906104ba36611c87565b6104c781839497936121f9565b928685611e6a565b91828452600260205265ffffffffffff604085205416155f1461051857602484847f60a299b0000000000000000000000000000000000000000000000000000000008252600452fd5b73ffffffffffffffffffffffffffffffffffffffff16903382036105b0575b50506020925080825260028352604082207fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000081541690558082526002835263ffffffff604083205460301c1680917fbd9ac67a6e2f6463b80927326310338bcbb4bdb7936ce1365ea3e01067e7b9f76040519480a38152f35b65ffffffffffff946105c2335f611d7d565b505096169586151596876106c1575b509073ffffffffffffffffffffffffffffffffffffffff91501694858552846020527fffffffff00000000000000000000000000000000000000000000000000000000604086209216918286526020526106653361066067ffffffffffffffff60408920541667ffffffffffffffff165f52600160205267ffffffffffffffff600160405f20015460401c1690565b61204a565b50901590816106b8575b5015610537576084925084604051927f3fe2751c000000000000000000000000000000000000000000000000000000008452336004850152602484015260448301526064820152fd5b9050155f61066f565b73ffffffffffffffffffffffffffffffffffffffff9291975065ffffffffffff6106ea42612d3e565b1610159690916105d1565b50346101ef5760406003193601126101ef5761070f611b50565b7fa56b76017453f399ec2327ba00375dbfb1fd070ff854341ad6191e6a2e2de19c73ffffffffffffffffffffffffffffffffffffffff61074d611c74565b926107566120ec565b169182845283602052610780816dffffffffffffffffffffffffffff600160408820015416612ca6565b9190848652856020526dffffffffffffffffffffffffffff6001604088200191167fffffffffffffffffffffffffffffffffffff00000000000000000000000000008254161790556107f26040519283928390929165ffffffffffff60209163ffffffff604085019616845216910152565b0390a280f35b50346101ef5760406003193601126101ef57610823610815611bc7565b61081d611b73565b9061204a565b60408051921515835263ffffffff91909116602083015290f35b50346101ef57806003193601126101ef576020604051620697808152f35b50346101ef5760406003193601126101ef576101c3610878611bc7565b610880611b73565b906108896120ec565b6124da565b50346101ef5760606003193601126101ef576108a8611b50565b6108b0611b73565b604435917fffffffff00000000000000000000000000000000000000000000000000000000831683036108e7576108239350611f1b565b8380fd5b50346101ef5760206003193601126101ef5760043567ffffffffffffffff81116104a35761091d903690600401611b96565b90602060405161092d8282611d2d565b84815281810191601f19810136843761094585611ec2565b936109536040519586611d2d565b858552601f1961096287611ec2565b01875b818110610ac757505086907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301915b87811015610a31578060051b82013583811215610a2d5782019081359167ffffffffffffffff8311610a295785018236038113610a295782610a07610a0d928d898c6001986040519687958487013784018281018481528e519283915e010190815203601f198101835282611d2d565b306124b8565b610a17828a611eda565b52610a228189611eda565b5001610997565b8a80fd5b8980fd5b88848860405191808301818452825180915260408401918060408360051b870101940192865b838810610a645786860387f35b9091929394838080837fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc08b60019603018752601f19601f838c518051918291828752018686015e8885828601015201160101970193019701969093929193610a57565b60608782018501528301610965565b50346101ef576020610af3610aea36611c87565b92919091611e6a565b604051908152f35b50346101ef5760406003193601126101ef57610b15611bc7565b67ffffffffffffffff610b26611c74565b91610b2f6120ec565b169067ffffffffffffffff8214610c16577ffeb69018ee8b8fd50ea86348f1267d07673379f72cffdeccec63853ee8ce8b48908284526001602052610b8e816dffffffffffffffffffffffffffff600160408820015460801c16612ca6565b9190848652600160205260016040872001907fffff0000000000000000000000000000ffffffffffffffffffffffffffffffff7dffffffffffffffffffffffffffff0000000000000000000000000000000083549260801b1691161790556107f26040519283928390929165ffffffffffff60209163ffffffff604085019616845216910152565b602483837f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b50346101ef5760206003193601126101ef576020610c8e610c61611b50565b73ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b6040519015158152f35b50346101ef57610ca736611c23565b916040517f8fb36037000000000000000000000000000000000000000000000000000000008152602081600481335afa908115610def578591610d70575b507fffffffff000000000000000000000000000000000000000000000000000000007f8fb3603700000000000000000000000000000000000000000000000000000000911603610d445791610d3f916101c3933390611e6a565b612227565b6024847f320ff74800000000000000000000000000000000000000000000000000000000815233600452fd5b90506020813d602011610de7575b81610d8b60209383611d2d565b81010312610de357517fffffffff0000000000000000000000000000000000000000000000000000000081168103610de3577fffffffff00000000000000000000000000000000000000000000000000000000610ce5565b8480fd5b3d9150610d7e565b6040513d87823e3d90fd5b50346101ef5760406003193601126101ef57610e14611bc7565b6024359067ffffffffffffffff821161049f57610e3e67ffffffffffffffff923690600401611bf5565b929091610e496120ec565b169182158015610ebe575b610e9257907f1256f5b5ecb89caec12db449738f2fbcd1ba5806cf38f35413f4e5c15bf6a450916107f2604051928392602084526020840191611e4a565b602484847f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b5067ffffffffffffffff8314610e54565b50346101ef57806003193601126101ef57602090604051908152f35b50346101ef5760406003193601126101ef57610f05611b50565b602435907fffffffff00000000000000000000000000000000000000000000000000000000821680920361049f5760209267ffffffffffffffff9273ffffffffffffffffffffffffffffffffffffffff6040931682528185528282209082528452205416604051908152f35b50346101ef5760206003193601126101ef576020610fb6610f90611bc7565b67ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b67ffffffffffffffff60405191168152f35b50346101ef5760406003193601126101ef57610fe2611bc7565b67ffffffffffffffff610ff3611bde565b91610ffc6120ec565b16908115801561108b575b610c165767ffffffffffffffff9082845260016020526001604085200180547fffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff6fffffffffffffffff00000000000000008460401b16911617905516907f7a8059630b897b5de4c08ade69f8b90c3ead1f8596d62d10b6c4d14a0afb4ae28380a380f35b5067ffffffffffffffff8214611007565b50346101ef5760206003193601126101ef5760206110c06110bb611b50565b611e0e565b63ffffffff60405191168152f35b50346101ef57806003193601126101ef57602060405162093a808152f35b50346101ef5760206003193601126101ef5763ffffffff6040602092600435815260028452205460301c16604051908152f35b50346101ef57806003193601126101ef57602060405167ffffffffffffffff8152f35b50346101ef5760206003193601126101ef576020611161600435611de4565b65ffffffffffff60405191168152f35b50346101ef5760406003193601126101ef5761118b611bc7565b67ffffffffffffffff61119c611bde565b916111a56120ec565b169081158015611220575b610c165767ffffffffffffffff908284526001602052600160408520018282167fffffffffffffffffffffffffffffffffffffffffffffffff000000000000000082541617905516907f1fd6dd7631312dfac2205b52913f99de03b4d7e381d5d27d3dbfe0713e6e63408380a380f35b5067ffffffffffffffff82146111b0565b50346101ef5760406003193601126101ef57608063ffffffff65ffffffffffff8161126b61125d611bc7565b611265611b73565b90611d7d565b93929590918560405197168752166020860152166040840152166060820152f35b50346101ef5760606003193601126101ef576112a6611bc7565b6112ae611b73565b906044359163ffffffff83168093036108e7576112c96120ec565b67ffffffffffffffff6112db83611cf4565b92169167ffffffffffffffff831461166b5782855260016020526040852073ffffffffffffffffffffffffffffffffffffffff83165f5260205265ffffffffffff60405f2054161590815f14611493576113459063ffffffff61133d42612d3e565b9116906120a1565b6040516040810181811067ffffffffffffffff821117611466579265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff9361144f7ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf97946dffffffffffffffffffffffffffff8b60408e60609b82528787168552602085019283528d81526001602052208989165f52602052858060405f20945116167fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000084541617835551167fffffffffffffffffffffffff0000000000000000000000000000ffffffffffff73ffffffffffffffffffffffffffff00000000000083549260301b169116179055565b60405198895216602088015260408701521693a380f35b6024887f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b5082855260016020526040852073ffffffffffffffffffffffffffffffffffffffff83165f526020526114dc6dffffffffffffffffffffffffffff60405f205460301c16612447565b50508463ffffffff82168181115f1461160d570363ffffffff81116115e0579260609265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff936115db67ffffffff0000000061156263ffffffff7ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf9a5b1661155d42612d3e565b6120a1565b9260201b166dffffffffffff00000000000000008360401b16178a17898c52600160205260408c208787165f5260205260405f20907fffffffffffffffffffffffff0000000000000000000000000000ffffffffffff73ffffffffffffffffffffffffffff00000000000083549260301b169116179055565b61144f565b6024877f4e487b710000000000000000000000000000000000000000000000000000000081526011600452fd5b50507ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf9260609265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff936115db67ffffffff0000000061156263ffffffff8d611553565b602485847f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b506116a136611c23565b90916116af828483336121a5565b9390158061183a575b6117f6576116c883828433611e6a565b63ffffffff869516158015906117dd575b6117cb575b50600354926117376116f082846121f9565b849073ffffffffffffffffffffffffffffffffffffffff7fffffffff0000000000000000000000000000000000000000000000000000000092165f521660205260405f2090565b60035567ffffffffffffffff811161179e5760405191611761601f8301601f191660200184611d2d565b818352368282011161179a5795602082849382998361178898970137830101523491612358565b5060035563ffffffff60405191168152f35b8680fd5b6024867f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b6117d6919450612227565b925f6116de565b5065ffffffffffff6117ee82611de4565b1615156116d9565b849173ffffffffffffffffffffffffffffffffffffffff6104526064957fffffffff00000000000000000000000000000000000000000000000000000000946121f9565b5063ffffffff8416156116b8565b503461190557604060031936011261190557611862611b50565b73ffffffffffffffffffffffffffffffffffffffff61187f611b73565b916118886120ec565b1690813b156119055773ffffffffffffffffffffffffffffffffffffffff60245f928360405195869485937f7a9e5e4b0000000000000000000000000000000000000000000000000000000085521660048401525af180156118fa576118ec575080f35b6118f891505f90611d2d565b005b6040513d5f823e3d90fd5b5f80fd5b3461190557604060031936011261190557611922611b50565b6024359081151580920361190557602073ffffffffffffffffffffffffffffffffffffffff7f90d4e7bb7e5d933792b3562e1741306f8be94837e1348dacef9b6f1df56eb138926119716120ec565b1692835f525f8252600160405f200180547fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff6eff00000000000000000000000000008460701b169116179055604051908152a2005b346119055760206003193601126119055760206110c06119e4611bc7565b611cf4565b34611905576020600319360112611905576020610fb6611a07611bc7565b67ffffffffffffffff165f52600160205267ffffffffffffffff600160405f20015460401c1690565b3461190557606060031936011261190557611a49611b50565b60243567ffffffffffffffff811161190557611a69903690600401611b96565b91906044359067ffffffffffffffff821680920361190557611a8c9392936120ec565b73ffffffffffffffffffffffffffffffffffffffff5f9416935b838110156118f8578060051b820135907fffffffff0000000000000000000000000000000000000000000000000000000082168092036119055783867f9ea6790c7dadfd01c9f8b9762b3682607af2c7e79e05a9f9fdf5580dde9491516020600195835f525f825260405f20815f52825260405f20857fffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000825416179055604051908152a301611aa6565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361190557565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361190557565b9181601f840112156119055782359167ffffffffffffffff8311611905576020808501948460051b01011161190557565b6004359067ffffffffffffffff8216820361190557565b6024359067ffffffffffffffff8216820361190557565b9181601f840112156119055782359167ffffffffffffffff8311611905576020838186019501011161190557565b9060406003198301126119055760043573ffffffffffffffffffffffffffffffffffffffff8116810361190557916024359067ffffffffffffffff821161190557611c7091600401611bf5565b9091565b6024359063ffffffff8216820361190557565b60606003198201126119055760043573ffffffffffffffffffffffffffffffffffffffff81168103611905579160243573ffffffffffffffffffffffffffffffffffffffff8116810361190557916044359067ffffffffffffffff821161190557611c7091600401611bf5565b67ffffffffffffffff165f526001602052611d286dffffffffffffffffffffffffffff600160405f20015460801c16612447565b505090565b90601f601f19910116810190811067ffffffffffffffff821117611d5057604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b9067ffffffffffffffff65ffffffffffff9392165f52600160205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f5260205260405f205490611dda6dffffffffffffffffffffffffffff8360301c16612447565b9490931693909291565b5f52600260205265ffffffffffff60405f205416611e0181612484565b15611e0b57505f90565b90565b73ffffffffffffffffffffffffffffffffffffffff165f525f602052611d286dffffffffffffffffffffffffffff600160405f20015416612447565b601f8260209493601f1993818652868601375f8582860101520116010190565b9290611eae73ffffffffffffffffffffffffffffffffffffffff93611ebc936040519586948160208701991689521660408501526060808501526080840191611e4a565b03601f198101835282611d2d565b51902090565b67ffffffffffffffff8111611d505760051b60200190565b8051821015611eee5760209160051b010190565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b919091611f4f8373ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b15611f5d575050505f905f90565b73ffffffffffffffffffffffffffffffffffffffff81163003611fcf5750611fc990600354929073ffffffffffffffffffffffffffffffffffffffff7fffffffff0000000000000000000000000000000000000000000000000000000092165f521660205260405f2090565b14905f90565b9073ffffffffffffffffffffffffffffffffffffffff61203093165f525f6020527fffffffff0000000000000000000000000000000000000000000000000000000060405f2091165f5260205267ffffffffffffffff60405f20541661204a565b9190156120435763ffffffff8216159190565b5f91508190565b67ffffffffffffffff818116036120645750506001905f90565b65ffffffffffff929161207691611d7d565b50509216801515908161208857509190565b905065ffffffffffff61209a42612d3e565b1610159190565b9065ffffffffffff8091169116019065ffffffffffff82116120bf57565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b6120f636336125bb565b90156120ff5750565b63ffffffff1661214e5767ffffffffffffffff61211b36612721565b5090507ff07e038f000000000000000000000000000000000000000000000000000000005f52336004521660245260445ffd5b6121a2604051602081019033825230604082015260608082015261219a81602060808201368152365f838301375f823683010152601f19601f360116010103601f198101835282611d2d565b519020612227565b50565b9092919073ffffffffffffffffffffffffffffffffffffffff841630036121d057611c7093506126ad565b91929060048410156121e657505050505f905f90565b611c70936121f3916121f9565b91611f1b565b9060041161190557357fffffffff000000000000000000000000000000000000000000000000000000001690565b5f81815260026020526040902054909190603081901c63ffffffff169065ffffffffffff168061227d57837f60a299b0000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b65ffffffffffff61228d42612d3e565b168111156122c157837f18cb6b7a000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b6122ce9093919293612484565b61232d578190805f52600260205260405f207fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000081541690557f76a2a46953689d4861a5d3f6ed883ad7e6af674a21f8e162707159fc9dde614d5f80a390565b7f78a5d6e4000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b9180471061241857815f92916020849351920190855af18080612405575b15612385575050611e0b612c8d565b156123cc5773ffffffffffffffffffffffffffffffffffffffff907f9996b315000000000000000000000000000000000000000000000000000000005f521660045260245ffd5b3d156123dd576040513d5f823e3d90fd5b7fd6bda275000000000000000000000000000000000000000000000000000000005f5260045ffd5b503d1515806123765750813b1515612376565b477fcf479181000000000000000000000000000000000000000000000000000000005f5260045260245260445ffd5b61245042612d3e565b63ffffffff82169165ffffffffffff604082901c811692168211612478575090915f91508190565b60201c63ffffffff1692565b65ffffffffffff62093a8091160165ffffffffffff81116120bf5765ffffffffffff806124b042612d3e565b169116111590565b905f8091602081519101845af480806124055715612385575050611e0b612c8d565b67ffffffffffffffff169067ffffffffffffffff821461258f57815f52600160205260405f2073ffffffffffffffffffffffffffffffffffffffff82165f5260205265ffffffffffff60405f205416156125895773ffffffffffffffffffffffffffffffffffffffff90825f52600160205260405f208282165f526020525f604081205516907ff229baa593af28c41b1d16b748cd7688f0c83aaf92d4be41c44005defe84c1665f80a3600190565b50505f90565b507f1871a90c000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b9060048110612624573073ffffffffffffffffffffffffffffffffffffffff83161461266c576125eb905f6129eb565b9290911580612635575b61262c576126029161204a565b90156126245763ffffffff808093169116908180821191180218169081159190565b50505f905f90565b5050505f905f90565b506126673073ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b6125f5565b905060041161190557600354305f90815280357fffffffff000000000000000000000000000000000000000000000000000000001660205260409020611fc9565b91906004821061262c573073ffffffffffffffffffffffffffffffffffffffff8416146126de57906125eb916129eb565b6126e892506121f9565b600354305f9081527fffffffff000000000000000000000000000000000000000000000000000000009092166020526040909120611fc9565b5f90600481106129e15780600411611905577fffffffff000000000000000000000000000000000000000000000000000000005f3516907f853551b800000000000000000000000000000000000000000000000000000000821480156129b8575b801561298f575b8015612966575b801561293d575b612931577f18ff183c0000000000000000000000000000000000000000000000000000000082148015612908575b80156128df575b6128a3577f25c471a0000000000000000000000000000000000000000000000000000000008214801561287a575b6128265750308252816020526040822090825260205267ffffffffffffffff6040822054169181929190565b90506024116101ef57806101ef575060043567ffffffffffffffff81168103611905576128739067ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b6001915f90565b507fb7d2b1620000000000000000000000000000000000000000000000000000000082146127fa565b9150506024116119055760043573ffffffffffffffffffffffffffffffffffffffff8116809103611905576128d790611e0e565b6001915f9190565b507f08d6122d0000000000000000000000000000000000000000000000000000000082146127cc565b507f167bd3950000000000000000000000000000000000000000000000000000000082146127c5565b5050506001905f905f90565b507fd22b5989000000000000000000000000000000000000000000000000000000008214612797565b507fa64d95ce000000000000000000000000000000000000000000000000000000008214612790565b507f52962952000000000000000000000000000000000000000000000000000000008214612789565b507f30cae187000000000000000000000000000000000000000000000000000000008214612782565b50505f905f905f90565b600482106129e1577fffffffff00000000000000000000000000000000000000000000000000000000612a1e83836121f9565b16917f853551b80000000000000000000000000000000000000000000000000000000083148015612c64575b8015612c3b575b8015612c12575b8015612be9575b612931577f18ff183c0000000000000000000000000000000000000000000000000000000083148015612bc0575b8015612b97575b612b62577f25c471a00000000000000000000000000000000000000000000000000000000083148015612b39575b612af0575050305f525f60205260405f20905f5260205267ffffffffffffffff60405f205416905f91905f90565b909150602411611905576004013567ffffffffffffffff81168103611905576128739067ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b507fb7d2b162000000000000000000000000000000000000000000000000000000008314612ac2565b909150602411611905576004013573ffffffffffffffffffffffffffffffffffffffff8116809103611905576128d790611e0e565b507f08d6122d000000000000000000000000000000000000000000000000000000008314612a94565b507f167bd395000000000000000000000000000000000000000000000000000000008314612a8d565b507fd22b5989000000000000000000000000000000000000000000000000000000008314612a5f565b507fa64d95ce000000000000000000000000000000000000000000000000000000008314612a58565b507f52962952000000000000000000000000000000000000000000000000000000008314612a51565b507f30cae187000000000000000000000000000000000000000000000000000000008314612a4a565b604051903d82523d5f602084013e60203d830101604052565b612cb763ffffffff91939293612447565b505092168063ffffffff84168181115f14612d24570363ffffffff81116120bf57612d0563ffffffff8067ffffffff00000000935b1680620697801181620697801802181661155d42612d3e565b9360201b166dffffffffffff00000000000000008460401b1617179190565b505067ffffffff00000000612d0563ffffffff805f612cec565b65ffffffffffff8111612d565765ffffffffffff1690565b7f6dfcc650000000000000000000000000000000000000000000000000000000005f52603060045260245260445ffdfea164736f6c634300081c000aa6eef7e35abe7026729641147f7915573c7e97b47efa546f5f6e3230263bcb49f98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf", +} + +// AccessManagerABI is the input ABI used to generate the binding from. +// Deprecated: Use AccessManagerMetaData.ABI instead. +var AccessManagerABI = AccessManagerMetaData.ABI + +// AccessManagerBin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use AccessManagerMetaData.Bin instead. +var AccessManagerBin = AccessManagerMetaData.Bin + +// DeployAccessManager deploys a new Ethereum contract, binding an instance of AccessManager to it. +func DeployAccessManager(auth *bind.TransactOpts, backend bind.ContractBackend, initialAdmin common.Address) (common.Address, *types.Transaction, *AccessManager, error) { + parsed, err := AccessManagerMetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(AccessManagerBin), backend, initialAdmin) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &AccessManager{AccessManagerCaller: AccessManagerCaller{contract: contract}, AccessManagerTransactor: AccessManagerTransactor{contract: contract}, AccessManagerFilterer: AccessManagerFilterer{contract: contract}}, nil +} + +// AccessManager is an auto generated Go binding around an Ethereum contract. +type AccessManager struct { + AccessManagerCaller // Read-only binding to the contract + AccessManagerTransactor // Write-only binding to the contract + AccessManagerFilterer // Log filterer for contract events +} + +// AccessManagerCaller is an auto generated read-only Go binding around an Ethereum contract. +type AccessManagerCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AccessManagerTransactor is an auto generated write-only Go binding around an Ethereum contract. +type AccessManagerTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AccessManagerFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type AccessManagerFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AccessManagerSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type AccessManagerSession struct { + Contract *AccessManager // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AccessManagerCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type AccessManagerCallerSession struct { + Contract *AccessManagerCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// AccessManagerTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type AccessManagerTransactorSession struct { + Contract *AccessManagerTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AccessManagerRaw is an auto generated low-level Go binding around an Ethereum contract. +type AccessManagerRaw struct { + Contract *AccessManager // Generic contract binding to access the raw methods on +} + +// AccessManagerCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type AccessManagerCallerRaw struct { + Contract *AccessManagerCaller // Generic read-only contract binding to access the raw methods on +} + +// AccessManagerTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type AccessManagerTransactorRaw struct { + Contract *AccessManagerTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewAccessManager creates a new instance of AccessManager, bound to a specific deployed contract. +func NewAccessManager(address common.Address, backend bind.ContractBackend) (*AccessManager, error) { + contract, err := bindAccessManager(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &AccessManager{AccessManagerCaller: AccessManagerCaller{contract: contract}, AccessManagerTransactor: AccessManagerTransactor{contract: contract}, AccessManagerFilterer: AccessManagerFilterer{contract: contract}}, nil +} + +// NewAccessManagerCaller creates a new read-only instance of AccessManager, bound to a specific deployed contract. +func NewAccessManagerCaller(address common.Address, caller bind.ContractCaller) (*AccessManagerCaller, error) { + contract, err := bindAccessManager(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &AccessManagerCaller{contract: contract}, nil +} + +// NewAccessManagerTransactor creates a new write-only instance of AccessManager, bound to a specific deployed contract. +func NewAccessManagerTransactor(address common.Address, transactor bind.ContractTransactor) (*AccessManagerTransactor, error) { + contract, err := bindAccessManager(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &AccessManagerTransactor{contract: contract}, nil +} + +// NewAccessManagerFilterer creates a new log filterer instance of AccessManager, bound to a specific deployed contract. +func NewAccessManagerFilterer(address common.Address, filterer bind.ContractFilterer) (*AccessManagerFilterer, error) { + contract, err := bindAccessManager(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &AccessManagerFilterer{contract: contract}, nil +} + +// bindAccessManager binds a generic wrapper to an already deployed contract. +func bindAccessManager(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := AccessManagerMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AccessManager *AccessManagerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AccessManager.Contract.AccessManagerCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AccessManager *AccessManagerRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AccessManager.Contract.AccessManagerTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AccessManager *AccessManagerRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AccessManager.Contract.AccessManagerTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AccessManager *AccessManagerCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AccessManager.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AccessManager *AccessManagerTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AccessManager.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AccessManager *AccessManagerTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AccessManager.Contract.contract.Transact(opts, method, params...) +} + +// ADMINROLE is a free data retrieval call binding the contract method 0x75b238fc. +// +// Solidity: function ADMIN_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerCaller) ADMINROLE(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "ADMIN_ROLE") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// ADMINROLE is a free data retrieval call binding the contract method 0x75b238fc. +// +// Solidity: function ADMIN_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerSession) ADMINROLE() (uint64, error) { + return _AccessManager.Contract.ADMINROLE(&_AccessManager.CallOpts) +} + +// ADMINROLE is a free data retrieval call binding the contract method 0x75b238fc. +// +// Solidity: function ADMIN_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerCallerSession) ADMINROLE() (uint64, error) { + return _AccessManager.Contract.ADMINROLE(&_AccessManager.CallOpts) +} + +// PUBLICROLE is a free data retrieval call binding the contract method 0x3ca7c02a. +// +// Solidity: function PUBLIC_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerCaller) PUBLICROLE(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "PUBLIC_ROLE") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// PUBLICROLE is a free data retrieval call binding the contract method 0x3ca7c02a. +// +// Solidity: function PUBLIC_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerSession) PUBLICROLE() (uint64, error) { + return _AccessManager.Contract.PUBLICROLE(&_AccessManager.CallOpts) +} + +// PUBLICROLE is a free data retrieval call binding the contract method 0x3ca7c02a. +// +// Solidity: function PUBLIC_ROLE() view returns(uint64) +func (_AccessManager *AccessManagerCallerSession) PUBLICROLE() (uint64, error) { + return _AccessManager.Contract.PUBLICROLE(&_AccessManager.CallOpts) +} + +// CanCall is a free data retrieval call binding the contract method 0xb7009613. +// +// Solidity: function canCall(address caller, address target, bytes4 selector) view returns(bool immediate, uint32 delay) +func (_AccessManager *AccessManagerCaller) CanCall(opts *bind.CallOpts, caller common.Address, target common.Address, selector [4]byte) (struct { + Immediate bool + Delay uint32 +}, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "canCall", caller, target, selector) + + outstruct := new(struct { + Immediate bool + Delay uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.Immediate = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.Delay = *abi.ConvertType(out[1], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// CanCall is a free data retrieval call binding the contract method 0xb7009613. +// +// Solidity: function canCall(address caller, address target, bytes4 selector) view returns(bool immediate, uint32 delay) +func (_AccessManager *AccessManagerSession) CanCall(caller common.Address, target common.Address, selector [4]byte) (struct { + Immediate bool + Delay uint32 +}, error) { + return _AccessManager.Contract.CanCall(&_AccessManager.CallOpts, caller, target, selector) +} + +// CanCall is a free data retrieval call binding the contract method 0xb7009613. +// +// Solidity: function canCall(address caller, address target, bytes4 selector) view returns(bool immediate, uint32 delay) +func (_AccessManager *AccessManagerCallerSession) CanCall(caller common.Address, target common.Address, selector [4]byte) (struct { + Immediate bool + Delay uint32 +}, error) { + return _AccessManager.Contract.CanCall(&_AccessManager.CallOpts, caller, target, selector) +} + +// Expiration is a free data retrieval call binding the contract method 0x4665096d. +// +// Solidity: function expiration() view returns(uint32) +func (_AccessManager *AccessManagerCaller) Expiration(opts *bind.CallOpts) (uint32, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "expiration") + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// Expiration is a free data retrieval call binding the contract method 0x4665096d. +// +// Solidity: function expiration() view returns(uint32) +func (_AccessManager *AccessManagerSession) Expiration() (uint32, error) { + return _AccessManager.Contract.Expiration(&_AccessManager.CallOpts) +} + +// Expiration is a free data retrieval call binding the contract method 0x4665096d. +// +// Solidity: function expiration() view returns(uint32) +func (_AccessManager *AccessManagerCallerSession) Expiration() (uint32, error) { + return _AccessManager.Contract.Expiration(&_AccessManager.CallOpts) +} + +// GetAccess is a free data retrieval call binding the contract method 0x3078f114. +// +// Solidity: function getAccess(uint64 roleId, address account) view returns(uint48 since, uint32 currentDelay, uint32 pendingDelay, uint48 effect) +func (_AccessManager *AccessManagerCaller) GetAccess(opts *bind.CallOpts, roleId uint64, account common.Address) (struct { + Since *big.Int + CurrentDelay uint32 + PendingDelay uint32 + Effect *big.Int +}, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getAccess", roleId, account) + + outstruct := new(struct { + Since *big.Int + CurrentDelay uint32 + PendingDelay uint32 + Effect *big.Int + }) + if err != nil { + return *outstruct, err + } + + outstruct.Since = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.CurrentDelay = *abi.ConvertType(out[1], new(uint32)).(*uint32) + outstruct.PendingDelay = *abi.ConvertType(out[2], new(uint32)).(*uint32) + outstruct.Effect = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + + return *outstruct, err + +} + +// GetAccess is a free data retrieval call binding the contract method 0x3078f114. +// +// Solidity: function getAccess(uint64 roleId, address account) view returns(uint48 since, uint32 currentDelay, uint32 pendingDelay, uint48 effect) +func (_AccessManager *AccessManagerSession) GetAccess(roleId uint64, account common.Address) (struct { + Since *big.Int + CurrentDelay uint32 + PendingDelay uint32 + Effect *big.Int +}, error) { + return _AccessManager.Contract.GetAccess(&_AccessManager.CallOpts, roleId, account) +} + +// GetAccess is a free data retrieval call binding the contract method 0x3078f114. +// +// Solidity: function getAccess(uint64 roleId, address account) view returns(uint48 since, uint32 currentDelay, uint32 pendingDelay, uint48 effect) +func (_AccessManager *AccessManagerCallerSession) GetAccess(roleId uint64, account common.Address) (struct { + Since *big.Int + CurrentDelay uint32 + PendingDelay uint32 + Effect *big.Int +}, error) { + return _AccessManager.Contract.GetAccess(&_AccessManager.CallOpts, roleId, account) +} + +// GetNonce is a free data retrieval call binding the contract method 0x4136a33c. +// +// Solidity: function getNonce(bytes32 id) view returns(uint32) +func (_AccessManager *AccessManagerCaller) GetNonce(opts *bind.CallOpts, id [32]byte) (uint32, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getNonce", id) + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// GetNonce is a free data retrieval call binding the contract method 0x4136a33c. +// +// Solidity: function getNonce(bytes32 id) view returns(uint32) +func (_AccessManager *AccessManagerSession) GetNonce(id [32]byte) (uint32, error) { + return _AccessManager.Contract.GetNonce(&_AccessManager.CallOpts, id) +} + +// GetNonce is a free data retrieval call binding the contract method 0x4136a33c. +// +// Solidity: function getNonce(bytes32 id) view returns(uint32) +func (_AccessManager *AccessManagerCallerSession) GetNonce(id [32]byte) (uint32, error) { + return _AccessManager.Contract.GetNonce(&_AccessManager.CallOpts, id) +} + +// GetRoleAdmin is a free data retrieval call binding the contract method 0x530dd456. +// +// Solidity: function getRoleAdmin(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerCaller) GetRoleAdmin(opts *bind.CallOpts, roleId uint64) (uint64, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getRoleAdmin", roleId) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// GetRoleAdmin is a free data retrieval call binding the contract method 0x530dd456. +// +// Solidity: function getRoleAdmin(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerSession) GetRoleAdmin(roleId uint64) (uint64, error) { + return _AccessManager.Contract.GetRoleAdmin(&_AccessManager.CallOpts, roleId) +} + +// GetRoleAdmin is a free data retrieval call binding the contract method 0x530dd456. +// +// Solidity: function getRoleAdmin(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerCallerSession) GetRoleAdmin(roleId uint64) (uint64, error) { + return _AccessManager.Contract.GetRoleAdmin(&_AccessManager.CallOpts, roleId) +} + +// GetRoleGrantDelay is a free data retrieval call binding the contract method 0x12be8727. +// +// Solidity: function getRoleGrantDelay(uint64 roleId) view returns(uint32) +func (_AccessManager *AccessManagerCaller) GetRoleGrantDelay(opts *bind.CallOpts, roleId uint64) (uint32, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getRoleGrantDelay", roleId) + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// GetRoleGrantDelay is a free data retrieval call binding the contract method 0x12be8727. +// +// Solidity: function getRoleGrantDelay(uint64 roleId) view returns(uint32) +func (_AccessManager *AccessManagerSession) GetRoleGrantDelay(roleId uint64) (uint32, error) { + return _AccessManager.Contract.GetRoleGrantDelay(&_AccessManager.CallOpts, roleId) +} + +// GetRoleGrantDelay is a free data retrieval call binding the contract method 0x12be8727. +// +// Solidity: function getRoleGrantDelay(uint64 roleId) view returns(uint32) +func (_AccessManager *AccessManagerCallerSession) GetRoleGrantDelay(roleId uint64) (uint32, error) { + return _AccessManager.Contract.GetRoleGrantDelay(&_AccessManager.CallOpts, roleId) +} + +// GetRoleGuardian is a free data retrieval call binding the contract method 0x0b0a93ba. +// +// Solidity: function getRoleGuardian(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerCaller) GetRoleGuardian(opts *bind.CallOpts, roleId uint64) (uint64, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getRoleGuardian", roleId) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// GetRoleGuardian is a free data retrieval call binding the contract method 0x0b0a93ba. +// +// Solidity: function getRoleGuardian(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerSession) GetRoleGuardian(roleId uint64) (uint64, error) { + return _AccessManager.Contract.GetRoleGuardian(&_AccessManager.CallOpts, roleId) +} + +// GetRoleGuardian is a free data retrieval call binding the contract method 0x0b0a93ba. +// +// Solidity: function getRoleGuardian(uint64 roleId) view returns(uint64) +func (_AccessManager *AccessManagerCallerSession) GetRoleGuardian(roleId uint64) (uint64, error) { + return _AccessManager.Contract.GetRoleGuardian(&_AccessManager.CallOpts, roleId) +} + +// GetSchedule is a free data retrieval call binding the contract method 0x3adc277a. +// +// Solidity: function getSchedule(bytes32 id) view returns(uint48) +func (_AccessManager *AccessManagerCaller) GetSchedule(opts *bind.CallOpts, id [32]byte) (*big.Int, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getSchedule", id) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSchedule is a free data retrieval call binding the contract method 0x3adc277a. +// +// Solidity: function getSchedule(bytes32 id) view returns(uint48) +func (_AccessManager *AccessManagerSession) GetSchedule(id [32]byte) (*big.Int, error) { + return _AccessManager.Contract.GetSchedule(&_AccessManager.CallOpts, id) +} + +// GetSchedule is a free data retrieval call binding the contract method 0x3adc277a. +// +// Solidity: function getSchedule(bytes32 id) view returns(uint48) +func (_AccessManager *AccessManagerCallerSession) GetSchedule(id [32]byte) (*big.Int, error) { + return _AccessManager.Contract.GetSchedule(&_AccessManager.CallOpts, id) +} + +// GetTargetAdminDelay is a free data retrieval call binding the contract method 0x4c1da1e2. +// +// Solidity: function getTargetAdminDelay(address target) view returns(uint32) +func (_AccessManager *AccessManagerCaller) GetTargetAdminDelay(opts *bind.CallOpts, target common.Address) (uint32, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getTargetAdminDelay", target) + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// GetTargetAdminDelay is a free data retrieval call binding the contract method 0x4c1da1e2. +// +// Solidity: function getTargetAdminDelay(address target) view returns(uint32) +func (_AccessManager *AccessManagerSession) GetTargetAdminDelay(target common.Address) (uint32, error) { + return _AccessManager.Contract.GetTargetAdminDelay(&_AccessManager.CallOpts, target) +} + +// GetTargetAdminDelay is a free data retrieval call binding the contract method 0x4c1da1e2. +// +// Solidity: function getTargetAdminDelay(address target) view returns(uint32) +func (_AccessManager *AccessManagerCallerSession) GetTargetAdminDelay(target common.Address) (uint32, error) { + return _AccessManager.Contract.GetTargetAdminDelay(&_AccessManager.CallOpts, target) +} + +// GetTargetFunctionRole is a free data retrieval call binding the contract method 0x6d5115bd. +// +// Solidity: function getTargetFunctionRole(address target, bytes4 selector) view returns(uint64) +func (_AccessManager *AccessManagerCaller) GetTargetFunctionRole(opts *bind.CallOpts, target common.Address, selector [4]byte) (uint64, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "getTargetFunctionRole", target, selector) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// GetTargetFunctionRole is a free data retrieval call binding the contract method 0x6d5115bd. +// +// Solidity: function getTargetFunctionRole(address target, bytes4 selector) view returns(uint64) +func (_AccessManager *AccessManagerSession) GetTargetFunctionRole(target common.Address, selector [4]byte) (uint64, error) { + return _AccessManager.Contract.GetTargetFunctionRole(&_AccessManager.CallOpts, target, selector) +} + +// GetTargetFunctionRole is a free data retrieval call binding the contract method 0x6d5115bd. +// +// Solidity: function getTargetFunctionRole(address target, bytes4 selector) view returns(uint64) +func (_AccessManager *AccessManagerCallerSession) GetTargetFunctionRole(target common.Address, selector [4]byte) (uint64, error) { + return _AccessManager.Contract.GetTargetFunctionRole(&_AccessManager.CallOpts, target, selector) +} + +// HasRole is a free data retrieval call binding the contract method 0xd1f856ee. +// +// Solidity: function hasRole(uint64 roleId, address account) view returns(bool isMember, uint32 executionDelay) +func (_AccessManager *AccessManagerCaller) HasRole(opts *bind.CallOpts, roleId uint64, account common.Address) (struct { + IsMember bool + ExecutionDelay uint32 +}, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "hasRole", roleId, account) + + outstruct := new(struct { + IsMember bool + ExecutionDelay uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsMember = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.ExecutionDelay = *abi.ConvertType(out[1], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// HasRole is a free data retrieval call binding the contract method 0xd1f856ee. +// +// Solidity: function hasRole(uint64 roleId, address account) view returns(bool isMember, uint32 executionDelay) +func (_AccessManager *AccessManagerSession) HasRole(roleId uint64, account common.Address) (struct { + IsMember bool + ExecutionDelay uint32 +}, error) { + return _AccessManager.Contract.HasRole(&_AccessManager.CallOpts, roleId, account) +} + +// HasRole is a free data retrieval call binding the contract method 0xd1f856ee. +// +// Solidity: function hasRole(uint64 roleId, address account) view returns(bool isMember, uint32 executionDelay) +func (_AccessManager *AccessManagerCallerSession) HasRole(roleId uint64, account common.Address) (struct { + IsMember bool + ExecutionDelay uint32 +}, error) { + return _AccessManager.Contract.HasRole(&_AccessManager.CallOpts, roleId, account) +} + +// HashOperation is a free data retrieval call binding the contract method 0xabd9bd2a. +// +// Solidity: function hashOperation(address caller, address target, bytes data) view returns(bytes32) +func (_AccessManager *AccessManagerCaller) HashOperation(opts *bind.CallOpts, caller common.Address, target common.Address, data []byte) ([32]byte, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "hashOperation", caller, target, data) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// HashOperation is a free data retrieval call binding the contract method 0xabd9bd2a. +// +// Solidity: function hashOperation(address caller, address target, bytes data) view returns(bytes32) +func (_AccessManager *AccessManagerSession) HashOperation(caller common.Address, target common.Address, data []byte) ([32]byte, error) { + return _AccessManager.Contract.HashOperation(&_AccessManager.CallOpts, caller, target, data) +} + +// HashOperation is a free data retrieval call binding the contract method 0xabd9bd2a. +// +// Solidity: function hashOperation(address caller, address target, bytes data) view returns(bytes32) +func (_AccessManager *AccessManagerCallerSession) HashOperation(caller common.Address, target common.Address, data []byte) ([32]byte, error) { + return _AccessManager.Contract.HashOperation(&_AccessManager.CallOpts, caller, target, data) +} + +// IsTargetClosed is a free data retrieval call binding the contract method 0xa166aa89. +// +// Solidity: function isTargetClosed(address target) view returns(bool) +func (_AccessManager *AccessManagerCaller) IsTargetClosed(opts *bind.CallOpts, target common.Address) (bool, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "isTargetClosed", target) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsTargetClosed is a free data retrieval call binding the contract method 0xa166aa89. +// +// Solidity: function isTargetClosed(address target) view returns(bool) +func (_AccessManager *AccessManagerSession) IsTargetClosed(target common.Address) (bool, error) { + return _AccessManager.Contract.IsTargetClosed(&_AccessManager.CallOpts, target) +} + +// IsTargetClosed is a free data retrieval call binding the contract method 0xa166aa89. +// +// Solidity: function isTargetClosed(address target) view returns(bool) +func (_AccessManager *AccessManagerCallerSession) IsTargetClosed(target common.Address) (bool, error) { + return _AccessManager.Contract.IsTargetClosed(&_AccessManager.CallOpts, target) +} + +// MinSetback is a free data retrieval call binding the contract method 0xcc1b6c81. +// +// Solidity: function minSetback() view returns(uint32) +func (_AccessManager *AccessManagerCaller) MinSetback(opts *bind.CallOpts) (uint32, error) { + var out []interface{} + err := _AccessManager.contract.Call(opts, &out, "minSetback") + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// MinSetback is a free data retrieval call binding the contract method 0xcc1b6c81. +// +// Solidity: function minSetback() view returns(uint32) +func (_AccessManager *AccessManagerSession) MinSetback() (uint32, error) { + return _AccessManager.Contract.MinSetback(&_AccessManager.CallOpts) +} + +// MinSetback is a free data retrieval call binding the contract method 0xcc1b6c81. +// +// Solidity: function minSetback() view returns(uint32) +func (_AccessManager *AccessManagerCallerSession) MinSetback() (uint32, error) { + return _AccessManager.Contract.MinSetback(&_AccessManager.CallOpts) +} + +// Cancel is a paid mutator transaction binding the contract method 0xd6bb62c6. +// +// Solidity: function cancel(address caller, address target, bytes data) returns(uint32) +func (_AccessManager *AccessManagerTransactor) Cancel(opts *bind.TransactOpts, caller common.Address, target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "cancel", caller, target, data) +} + +// Cancel is a paid mutator transaction binding the contract method 0xd6bb62c6. +// +// Solidity: function cancel(address caller, address target, bytes data) returns(uint32) +func (_AccessManager *AccessManagerSession) Cancel(caller common.Address, target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.Cancel(&_AccessManager.TransactOpts, caller, target, data) +} + +// Cancel is a paid mutator transaction binding the contract method 0xd6bb62c6. +// +// Solidity: function cancel(address caller, address target, bytes data) returns(uint32) +func (_AccessManager *AccessManagerTransactorSession) Cancel(caller common.Address, target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.Cancel(&_AccessManager.TransactOpts, caller, target, data) +} + +// ConsumeScheduledOp is a paid mutator transaction binding the contract method 0x94c7d7ee. +// +// Solidity: function consumeScheduledOp(address caller, bytes data) returns() +func (_AccessManager *AccessManagerTransactor) ConsumeScheduledOp(opts *bind.TransactOpts, caller common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "consumeScheduledOp", caller, data) +} + +// ConsumeScheduledOp is a paid mutator transaction binding the contract method 0x94c7d7ee. +// +// Solidity: function consumeScheduledOp(address caller, bytes data) returns() +func (_AccessManager *AccessManagerSession) ConsumeScheduledOp(caller common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.ConsumeScheduledOp(&_AccessManager.TransactOpts, caller, data) +} + +// ConsumeScheduledOp is a paid mutator transaction binding the contract method 0x94c7d7ee. +// +// Solidity: function consumeScheduledOp(address caller, bytes data) returns() +func (_AccessManager *AccessManagerTransactorSession) ConsumeScheduledOp(caller common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.ConsumeScheduledOp(&_AccessManager.TransactOpts, caller, data) +} + +// Execute is a paid mutator transaction binding the contract method 0x1cff79cd. +// +// Solidity: function execute(address target, bytes data) payable returns(uint32) +func (_AccessManager *AccessManagerTransactor) Execute(opts *bind.TransactOpts, target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "execute", target, data) +} + +// Execute is a paid mutator transaction binding the contract method 0x1cff79cd. +// +// Solidity: function execute(address target, bytes data) payable returns(uint32) +func (_AccessManager *AccessManagerSession) Execute(target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.Execute(&_AccessManager.TransactOpts, target, data) +} + +// Execute is a paid mutator transaction binding the contract method 0x1cff79cd. +// +// Solidity: function execute(address target, bytes data) payable returns(uint32) +func (_AccessManager *AccessManagerTransactorSession) Execute(target common.Address, data []byte) (*types.Transaction, error) { + return _AccessManager.Contract.Execute(&_AccessManager.TransactOpts, target, data) +} + +// GrantRole is a paid mutator transaction binding the contract method 0x25c471a0. +// +// Solidity: function grantRole(uint64 roleId, address account, uint32 executionDelay) returns() +func (_AccessManager *AccessManagerTransactor) GrantRole(opts *bind.TransactOpts, roleId uint64, account common.Address, executionDelay uint32) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "grantRole", roleId, account, executionDelay) +} + +// GrantRole is a paid mutator transaction binding the contract method 0x25c471a0. +// +// Solidity: function grantRole(uint64 roleId, address account, uint32 executionDelay) returns() +func (_AccessManager *AccessManagerSession) GrantRole(roleId uint64, account common.Address, executionDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.GrantRole(&_AccessManager.TransactOpts, roleId, account, executionDelay) +} + +// GrantRole is a paid mutator transaction binding the contract method 0x25c471a0. +// +// Solidity: function grantRole(uint64 roleId, address account, uint32 executionDelay) returns() +func (_AccessManager *AccessManagerTransactorSession) GrantRole(roleId uint64, account common.Address, executionDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.GrantRole(&_AccessManager.TransactOpts, roleId, account, executionDelay) +} + +// LabelRole is a paid mutator transaction binding the contract method 0x853551b8. +// +// Solidity: function labelRole(uint64 roleId, string label) returns() +func (_AccessManager *AccessManagerTransactor) LabelRole(opts *bind.TransactOpts, roleId uint64, label string) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "labelRole", roleId, label) +} + +// LabelRole is a paid mutator transaction binding the contract method 0x853551b8. +// +// Solidity: function labelRole(uint64 roleId, string label) returns() +func (_AccessManager *AccessManagerSession) LabelRole(roleId uint64, label string) (*types.Transaction, error) { + return _AccessManager.Contract.LabelRole(&_AccessManager.TransactOpts, roleId, label) +} + +// LabelRole is a paid mutator transaction binding the contract method 0x853551b8. +// +// Solidity: function labelRole(uint64 roleId, string label) returns() +func (_AccessManager *AccessManagerTransactorSession) LabelRole(roleId uint64, label string) (*types.Transaction, error) { + return _AccessManager.Contract.LabelRole(&_AccessManager.TransactOpts, roleId, label) +} + +// Multicall is a paid mutator transaction binding the contract method 0xac9650d8. +// +// Solidity: function multicall(bytes[] data) returns(bytes[] results) +func (_AccessManager *AccessManagerTransactor) Multicall(opts *bind.TransactOpts, data [][]byte) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "multicall", data) +} + +// Multicall is a paid mutator transaction binding the contract method 0xac9650d8. +// +// Solidity: function multicall(bytes[] data) returns(bytes[] results) +func (_AccessManager *AccessManagerSession) Multicall(data [][]byte) (*types.Transaction, error) { + return _AccessManager.Contract.Multicall(&_AccessManager.TransactOpts, data) +} + +// Multicall is a paid mutator transaction binding the contract method 0xac9650d8. +// +// Solidity: function multicall(bytes[] data) returns(bytes[] results) +func (_AccessManager *AccessManagerTransactorSession) Multicall(data [][]byte) (*types.Transaction, error) { + return _AccessManager.Contract.Multicall(&_AccessManager.TransactOpts, data) +} + +// RenounceRole is a paid mutator transaction binding the contract method 0xfe0776f5. +// +// Solidity: function renounceRole(uint64 roleId, address callerConfirmation) returns() +func (_AccessManager *AccessManagerTransactor) RenounceRole(opts *bind.TransactOpts, roleId uint64, callerConfirmation common.Address) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "renounceRole", roleId, callerConfirmation) +} + +// RenounceRole is a paid mutator transaction binding the contract method 0xfe0776f5. +// +// Solidity: function renounceRole(uint64 roleId, address callerConfirmation) returns() +func (_AccessManager *AccessManagerSession) RenounceRole(roleId uint64, callerConfirmation common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.RenounceRole(&_AccessManager.TransactOpts, roleId, callerConfirmation) +} + +// RenounceRole is a paid mutator transaction binding the contract method 0xfe0776f5. +// +// Solidity: function renounceRole(uint64 roleId, address callerConfirmation) returns() +func (_AccessManager *AccessManagerTransactorSession) RenounceRole(roleId uint64, callerConfirmation common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.RenounceRole(&_AccessManager.TransactOpts, roleId, callerConfirmation) +} + +// RevokeRole is a paid mutator transaction binding the contract method 0xb7d2b162. +// +// Solidity: function revokeRole(uint64 roleId, address account) returns() +func (_AccessManager *AccessManagerTransactor) RevokeRole(opts *bind.TransactOpts, roleId uint64, account common.Address) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "revokeRole", roleId, account) +} + +// RevokeRole is a paid mutator transaction binding the contract method 0xb7d2b162. +// +// Solidity: function revokeRole(uint64 roleId, address account) returns() +func (_AccessManager *AccessManagerSession) RevokeRole(roleId uint64, account common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.RevokeRole(&_AccessManager.TransactOpts, roleId, account) +} + +// RevokeRole is a paid mutator transaction binding the contract method 0xb7d2b162. +// +// Solidity: function revokeRole(uint64 roleId, address account) returns() +func (_AccessManager *AccessManagerTransactorSession) RevokeRole(roleId uint64, account common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.RevokeRole(&_AccessManager.TransactOpts, roleId, account) +} + +// Schedule is a paid mutator transaction binding the contract method 0xf801a698. +// +// Solidity: function schedule(address target, bytes data, uint48 when) returns(bytes32 operationId, uint32 nonce) +func (_AccessManager *AccessManagerTransactor) Schedule(opts *bind.TransactOpts, target common.Address, data []byte, when *big.Int) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "schedule", target, data, when) +} + +// Schedule is a paid mutator transaction binding the contract method 0xf801a698. +// +// Solidity: function schedule(address target, bytes data, uint48 when) returns(bytes32 operationId, uint32 nonce) +func (_AccessManager *AccessManagerSession) Schedule(target common.Address, data []byte, when *big.Int) (*types.Transaction, error) { + return _AccessManager.Contract.Schedule(&_AccessManager.TransactOpts, target, data, when) +} + +// Schedule is a paid mutator transaction binding the contract method 0xf801a698. +// +// Solidity: function schedule(address target, bytes data, uint48 when) returns(bytes32 operationId, uint32 nonce) +func (_AccessManager *AccessManagerTransactorSession) Schedule(target common.Address, data []byte, when *big.Int) (*types.Transaction, error) { + return _AccessManager.Contract.Schedule(&_AccessManager.TransactOpts, target, data, when) +} + +// SetGrantDelay is a paid mutator transaction binding the contract method 0xa64d95ce. +// +// Solidity: function setGrantDelay(uint64 roleId, uint32 newDelay) returns() +func (_AccessManager *AccessManagerTransactor) SetGrantDelay(opts *bind.TransactOpts, roleId uint64, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setGrantDelay", roleId, newDelay) +} + +// SetGrantDelay is a paid mutator transaction binding the contract method 0xa64d95ce. +// +// Solidity: function setGrantDelay(uint64 roleId, uint32 newDelay) returns() +func (_AccessManager *AccessManagerSession) SetGrantDelay(roleId uint64, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.SetGrantDelay(&_AccessManager.TransactOpts, roleId, newDelay) +} + +// SetGrantDelay is a paid mutator transaction binding the contract method 0xa64d95ce. +// +// Solidity: function setGrantDelay(uint64 roleId, uint32 newDelay) returns() +func (_AccessManager *AccessManagerTransactorSession) SetGrantDelay(roleId uint64, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.SetGrantDelay(&_AccessManager.TransactOpts, roleId, newDelay) +} + +// SetRoleAdmin is a paid mutator transaction binding the contract method 0x30cae187. +// +// Solidity: function setRoleAdmin(uint64 roleId, uint64 admin) returns() +func (_AccessManager *AccessManagerTransactor) SetRoleAdmin(opts *bind.TransactOpts, roleId uint64, admin uint64) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setRoleAdmin", roleId, admin) +} + +// SetRoleAdmin is a paid mutator transaction binding the contract method 0x30cae187. +// +// Solidity: function setRoleAdmin(uint64 roleId, uint64 admin) returns() +func (_AccessManager *AccessManagerSession) SetRoleAdmin(roleId uint64, admin uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetRoleAdmin(&_AccessManager.TransactOpts, roleId, admin) +} + +// SetRoleAdmin is a paid mutator transaction binding the contract method 0x30cae187. +// +// Solidity: function setRoleAdmin(uint64 roleId, uint64 admin) returns() +func (_AccessManager *AccessManagerTransactorSession) SetRoleAdmin(roleId uint64, admin uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetRoleAdmin(&_AccessManager.TransactOpts, roleId, admin) +} + +// SetRoleGuardian is a paid mutator transaction binding the contract method 0x52962952. +// +// Solidity: function setRoleGuardian(uint64 roleId, uint64 guardian) returns() +func (_AccessManager *AccessManagerTransactor) SetRoleGuardian(opts *bind.TransactOpts, roleId uint64, guardian uint64) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setRoleGuardian", roleId, guardian) +} + +// SetRoleGuardian is a paid mutator transaction binding the contract method 0x52962952. +// +// Solidity: function setRoleGuardian(uint64 roleId, uint64 guardian) returns() +func (_AccessManager *AccessManagerSession) SetRoleGuardian(roleId uint64, guardian uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetRoleGuardian(&_AccessManager.TransactOpts, roleId, guardian) +} + +// SetRoleGuardian is a paid mutator transaction binding the contract method 0x52962952. +// +// Solidity: function setRoleGuardian(uint64 roleId, uint64 guardian) returns() +func (_AccessManager *AccessManagerTransactorSession) SetRoleGuardian(roleId uint64, guardian uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetRoleGuardian(&_AccessManager.TransactOpts, roleId, guardian) +} + +// SetTargetAdminDelay is a paid mutator transaction binding the contract method 0xd22b5989. +// +// Solidity: function setTargetAdminDelay(address target, uint32 newDelay) returns() +func (_AccessManager *AccessManagerTransactor) SetTargetAdminDelay(opts *bind.TransactOpts, target common.Address, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setTargetAdminDelay", target, newDelay) +} + +// SetTargetAdminDelay is a paid mutator transaction binding the contract method 0xd22b5989. +// +// Solidity: function setTargetAdminDelay(address target, uint32 newDelay) returns() +func (_AccessManager *AccessManagerSession) SetTargetAdminDelay(target common.Address, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetAdminDelay(&_AccessManager.TransactOpts, target, newDelay) +} + +// SetTargetAdminDelay is a paid mutator transaction binding the contract method 0xd22b5989. +// +// Solidity: function setTargetAdminDelay(address target, uint32 newDelay) returns() +func (_AccessManager *AccessManagerTransactorSession) SetTargetAdminDelay(target common.Address, newDelay uint32) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetAdminDelay(&_AccessManager.TransactOpts, target, newDelay) +} + +// SetTargetClosed is a paid mutator transaction binding the contract method 0x167bd395. +// +// Solidity: function setTargetClosed(address target, bool closed) returns() +func (_AccessManager *AccessManagerTransactor) SetTargetClosed(opts *bind.TransactOpts, target common.Address, closed bool) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setTargetClosed", target, closed) +} + +// SetTargetClosed is a paid mutator transaction binding the contract method 0x167bd395. +// +// Solidity: function setTargetClosed(address target, bool closed) returns() +func (_AccessManager *AccessManagerSession) SetTargetClosed(target common.Address, closed bool) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetClosed(&_AccessManager.TransactOpts, target, closed) +} + +// SetTargetClosed is a paid mutator transaction binding the contract method 0x167bd395. +// +// Solidity: function setTargetClosed(address target, bool closed) returns() +func (_AccessManager *AccessManagerTransactorSession) SetTargetClosed(target common.Address, closed bool) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetClosed(&_AccessManager.TransactOpts, target, closed) +} + +// SetTargetFunctionRole is a paid mutator transaction binding the contract method 0x08d6122d. +// +// Solidity: function setTargetFunctionRole(address target, bytes4[] selectors, uint64 roleId) returns() +func (_AccessManager *AccessManagerTransactor) SetTargetFunctionRole(opts *bind.TransactOpts, target common.Address, selectors [][4]byte, roleId uint64) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "setTargetFunctionRole", target, selectors, roleId) +} + +// SetTargetFunctionRole is a paid mutator transaction binding the contract method 0x08d6122d. +// +// Solidity: function setTargetFunctionRole(address target, bytes4[] selectors, uint64 roleId) returns() +func (_AccessManager *AccessManagerSession) SetTargetFunctionRole(target common.Address, selectors [][4]byte, roleId uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetFunctionRole(&_AccessManager.TransactOpts, target, selectors, roleId) +} + +// SetTargetFunctionRole is a paid mutator transaction binding the contract method 0x08d6122d. +// +// Solidity: function setTargetFunctionRole(address target, bytes4[] selectors, uint64 roleId) returns() +func (_AccessManager *AccessManagerTransactorSession) SetTargetFunctionRole(target common.Address, selectors [][4]byte, roleId uint64) (*types.Transaction, error) { + return _AccessManager.Contract.SetTargetFunctionRole(&_AccessManager.TransactOpts, target, selectors, roleId) +} + +// UpdateAuthority is a paid mutator transaction binding the contract method 0x18ff183c. +// +// Solidity: function updateAuthority(address target, address newAuthority) returns() +func (_AccessManager *AccessManagerTransactor) UpdateAuthority(opts *bind.TransactOpts, target common.Address, newAuthority common.Address) (*types.Transaction, error) { + return _AccessManager.contract.Transact(opts, "updateAuthority", target, newAuthority) +} + +// UpdateAuthority is a paid mutator transaction binding the contract method 0x18ff183c. +// +// Solidity: function updateAuthority(address target, address newAuthority) returns() +func (_AccessManager *AccessManagerSession) UpdateAuthority(target common.Address, newAuthority common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.UpdateAuthority(&_AccessManager.TransactOpts, target, newAuthority) +} + +// UpdateAuthority is a paid mutator transaction binding the contract method 0x18ff183c. +// +// Solidity: function updateAuthority(address target, address newAuthority) returns() +func (_AccessManager *AccessManagerTransactorSession) UpdateAuthority(target common.Address, newAuthority common.Address) (*types.Transaction, error) { + return _AccessManager.Contract.UpdateAuthority(&_AccessManager.TransactOpts, target, newAuthority) +} + +// AccessManagerOperationCanceledIterator is returned from FilterOperationCanceled and is used to iterate over the raw logs and unpacked data for OperationCanceled events raised by the AccessManager contract. +type AccessManagerOperationCanceledIterator struct { + Event *AccessManagerOperationCanceled // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerOperationCanceledIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationCanceled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationCanceled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerOperationCanceledIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerOperationCanceledIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerOperationCanceled represents a OperationCanceled event raised by the AccessManager contract. +type AccessManagerOperationCanceled struct { + OperationId [32]byte + Nonce uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperationCanceled is a free log retrieval operation binding the contract event 0xbd9ac67a6e2f6463b80927326310338bcbb4bdb7936ce1365ea3e01067e7b9f7. +// +// Solidity: event OperationCanceled(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) FilterOperationCanceled(opts *bind.FilterOpts, operationId [][32]byte, nonce []uint32) (*AccessManagerOperationCanceledIterator, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "OperationCanceled", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return &AccessManagerOperationCanceledIterator{contract: _AccessManager.contract, event: "OperationCanceled", logs: logs, sub: sub}, nil +} + +// WatchOperationCanceled is a free log subscription operation binding the contract event 0xbd9ac67a6e2f6463b80927326310338bcbb4bdb7936ce1365ea3e01067e7b9f7. +// +// Solidity: event OperationCanceled(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) WatchOperationCanceled(opts *bind.WatchOpts, sink chan<- *AccessManagerOperationCanceled, operationId [][32]byte, nonce []uint32) (event.Subscription, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "OperationCanceled", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerOperationCanceled) + if err := _AccessManager.contract.UnpackLog(event, "OperationCanceled", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperationCanceled is a log parse operation binding the contract event 0xbd9ac67a6e2f6463b80927326310338bcbb4bdb7936ce1365ea3e01067e7b9f7. +// +// Solidity: event OperationCanceled(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) ParseOperationCanceled(log types.Log) (*AccessManagerOperationCanceled, error) { + event := new(AccessManagerOperationCanceled) + if err := _AccessManager.contract.UnpackLog(event, "OperationCanceled", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerOperationExecutedIterator is returned from FilterOperationExecuted and is used to iterate over the raw logs and unpacked data for OperationExecuted events raised by the AccessManager contract. +type AccessManagerOperationExecutedIterator struct { + Event *AccessManagerOperationExecuted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerOperationExecutedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationExecuted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationExecuted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerOperationExecutedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerOperationExecutedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerOperationExecuted represents a OperationExecuted event raised by the AccessManager contract. +type AccessManagerOperationExecuted struct { + OperationId [32]byte + Nonce uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperationExecuted is a free log retrieval operation binding the contract event 0x76a2a46953689d4861a5d3f6ed883ad7e6af674a21f8e162707159fc9dde614d. +// +// Solidity: event OperationExecuted(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) FilterOperationExecuted(opts *bind.FilterOpts, operationId [][32]byte, nonce []uint32) (*AccessManagerOperationExecutedIterator, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "OperationExecuted", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return &AccessManagerOperationExecutedIterator{contract: _AccessManager.contract, event: "OperationExecuted", logs: logs, sub: sub}, nil +} + +// WatchOperationExecuted is a free log subscription operation binding the contract event 0x76a2a46953689d4861a5d3f6ed883ad7e6af674a21f8e162707159fc9dde614d. +// +// Solidity: event OperationExecuted(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) WatchOperationExecuted(opts *bind.WatchOpts, sink chan<- *AccessManagerOperationExecuted, operationId [][32]byte, nonce []uint32) (event.Subscription, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "OperationExecuted", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerOperationExecuted) + if err := _AccessManager.contract.UnpackLog(event, "OperationExecuted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperationExecuted is a log parse operation binding the contract event 0x76a2a46953689d4861a5d3f6ed883ad7e6af674a21f8e162707159fc9dde614d. +// +// Solidity: event OperationExecuted(bytes32 indexed operationId, uint32 indexed nonce) +func (_AccessManager *AccessManagerFilterer) ParseOperationExecuted(log types.Log) (*AccessManagerOperationExecuted, error) { + event := new(AccessManagerOperationExecuted) + if err := _AccessManager.contract.UnpackLog(event, "OperationExecuted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerOperationScheduledIterator is returned from FilterOperationScheduled and is used to iterate over the raw logs and unpacked data for OperationScheduled events raised by the AccessManager contract. +type AccessManagerOperationScheduledIterator struct { + Event *AccessManagerOperationScheduled // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerOperationScheduledIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationScheduled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerOperationScheduled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerOperationScheduledIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerOperationScheduledIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerOperationScheduled represents a OperationScheduled event raised by the AccessManager contract. +type AccessManagerOperationScheduled struct { + OperationId [32]byte + Nonce uint32 + Schedule *big.Int + Caller common.Address + Target common.Address + Data []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOperationScheduled is a free log retrieval operation binding the contract event 0x82a2da5dee54ea8021c6545b4444620291e07ee83be6dd57edb175062715f3b4. +// +// Solidity: event OperationScheduled(bytes32 indexed operationId, uint32 indexed nonce, uint48 schedule, address caller, address target, bytes data) +func (_AccessManager *AccessManagerFilterer) FilterOperationScheduled(opts *bind.FilterOpts, operationId [][32]byte, nonce []uint32) (*AccessManagerOperationScheduledIterator, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "OperationScheduled", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return &AccessManagerOperationScheduledIterator{contract: _AccessManager.contract, event: "OperationScheduled", logs: logs, sub: sub}, nil +} + +// WatchOperationScheduled is a free log subscription operation binding the contract event 0x82a2da5dee54ea8021c6545b4444620291e07ee83be6dd57edb175062715f3b4. +// +// Solidity: event OperationScheduled(bytes32 indexed operationId, uint32 indexed nonce, uint48 schedule, address caller, address target, bytes data) +func (_AccessManager *AccessManagerFilterer) WatchOperationScheduled(opts *bind.WatchOpts, sink chan<- *AccessManagerOperationScheduled, operationId [][32]byte, nonce []uint32) (event.Subscription, error) { + + var operationIdRule []interface{} + for _, operationIdItem := range operationId { + operationIdRule = append(operationIdRule, operationIdItem) + } + var nonceRule []interface{} + for _, nonceItem := range nonce { + nonceRule = append(nonceRule, nonceItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "OperationScheduled", operationIdRule, nonceRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerOperationScheduled) + if err := _AccessManager.contract.UnpackLog(event, "OperationScheduled", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOperationScheduled is a log parse operation binding the contract event 0x82a2da5dee54ea8021c6545b4444620291e07ee83be6dd57edb175062715f3b4. +// +// Solidity: event OperationScheduled(bytes32 indexed operationId, uint32 indexed nonce, uint48 schedule, address caller, address target, bytes data) +func (_AccessManager *AccessManagerFilterer) ParseOperationScheduled(log types.Log) (*AccessManagerOperationScheduled, error) { + event := new(AccessManagerOperationScheduled) + if err := _AccessManager.contract.UnpackLog(event, "OperationScheduled", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleAdminChangedIterator is returned from FilterRoleAdminChanged and is used to iterate over the raw logs and unpacked data for RoleAdminChanged events raised by the AccessManager contract. +type AccessManagerRoleAdminChangedIterator struct { + Event *AccessManagerRoleAdminChanged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleAdminChangedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleAdminChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleAdminChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleAdminChangedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleAdminChangedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleAdminChanged represents a RoleAdminChanged event raised by the AccessManager contract. +type AccessManagerRoleAdminChanged struct { + RoleId uint64 + Admin uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleAdminChanged is a free log retrieval operation binding the contract event 0x1fd6dd7631312dfac2205b52913f99de03b4d7e381d5d27d3dbfe0713e6e6340. +// +// Solidity: event RoleAdminChanged(uint64 indexed roleId, uint64 indexed admin) +func (_AccessManager *AccessManagerFilterer) FilterRoleAdminChanged(opts *bind.FilterOpts, roleId []uint64, admin []uint64) (*AccessManagerRoleAdminChangedIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var adminRule []interface{} + for _, adminItem := range admin { + adminRule = append(adminRule, adminItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleAdminChanged", roleIdRule, adminRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleAdminChangedIterator{contract: _AccessManager.contract, event: "RoleAdminChanged", logs: logs, sub: sub}, nil +} + +// WatchRoleAdminChanged is a free log subscription operation binding the contract event 0x1fd6dd7631312dfac2205b52913f99de03b4d7e381d5d27d3dbfe0713e6e6340. +// +// Solidity: event RoleAdminChanged(uint64 indexed roleId, uint64 indexed admin) +func (_AccessManager *AccessManagerFilterer) WatchRoleAdminChanged(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleAdminChanged, roleId []uint64, admin []uint64) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var adminRule []interface{} + for _, adminItem := range admin { + adminRule = append(adminRule, adminItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleAdminChanged", roleIdRule, adminRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleAdminChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleAdminChanged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleAdminChanged is a log parse operation binding the contract event 0x1fd6dd7631312dfac2205b52913f99de03b4d7e381d5d27d3dbfe0713e6e6340. +// +// Solidity: event RoleAdminChanged(uint64 indexed roleId, uint64 indexed admin) +func (_AccessManager *AccessManagerFilterer) ParseRoleAdminChanged(log types.Log) (*AccessManagerRoleAdminChanged, error) { + event := new(AccessManagerRoleAdminChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleAdminChanged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleGrantDelayChangedIterator is returned from FilterRoleGrantDelayChanged and is used to iterate over the raw logs and unpacked data for RoleGrantDelayChanged events raised by the AccessManager contract. +type AccessManagerRoleGrantDelayChangedIterator struct { + Event *AccessManagerRoleGrantDelayChanged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleGrantDelayChangedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGrantDelayChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGrantDelayChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleGrantDelayChangedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleGrantDelayChangedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleGrantDelayChanged represents a RoleGrantDelayChanged event raised by the AccessManager contract. +type AccessManagerRoleGrantDelayChanged struct { + RoleId uint64 + Delay uint32 + Since *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleGrantDelayChanged is a free log retrieval operation binding the contract event 0xfeb69018ee8b8fd50ea86348f1267d07673379f72cffdeccec63853ee8ce8b48. +// +// Solidity: event RoleGrantDelayChanged(uint64 indexed roleId, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) FilterRoleGrantDelayChanged(opts *bind.FilterOpts, roleId []uint64) (*AccessManagerRoleGrantDelayChangedIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleGrantDelayChanged", roleIdRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleGrantDelayChangedIterator{contract: _AccessManager.contract, event: "RoleGrantDelayChanged", logs: logs, sub: sub}, nil +} + +// WatchRoleGrantDelayChanged is a free log subscription operation binding the contract event 0xfeb69018ee8b8fd50ea86348f1267d07673379f72cffdeccec63853ee8ce8b48. +// +// Solidity: event RoleGrantDelayChanged(uint64 indexed roleId, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) WatchRoleGrantDelayChanged(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleGrantDelayChanged, roleId []uint64) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleGrantDelayChanged", roleIdRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleGrantDelayChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleGrantDelayChanged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleGrantDelayChanged is a log parse operation binding the contract event 0xfeb69018ee8b8fd50ea86348f1267d07673379f72cffdeccec63853ee8ce8b48. +// +// Solidity: event RoleGrantDelayChanged(uint64 indexed roleId, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) ParseRoleGrantDelayChanged(log types.Log) (*AccessManagerRoleGrantDelayChanged, error) { + event := new(AccessManagerRoleGrantDelayChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleGrantDelayChanged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleGrantedIterator is returned from FilterRoleGranted and is used to iterate over the raw logs and unpacked data for RoleGranted events raised by the AccessManager contract. +type AccessManagerRoleGrantedIterator struct { + Event *AccessManagerRoleGranted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleGrantedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGranted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGranted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleGrantedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleGrantedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleGranted represents a RoleGranted event raised by the AccessManager contract. +type AccessManagerRoleGranted struct { + RoleId uint64 + Account common.Address + Delay uint32 + Since *big.Int + NewMember bool + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleGranted is a free log retrieval operation binding the contract event 0xf98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf. +// +// Solidity: event RoleGranted(uint64 indexed roleId, address indexed account, uint32 delay, uint48 since, bool newMember) +func (_AccessManager *AccessManagerFilterer) FilterRoleGranted(opts *bind.FilterOpts, roleId []uint64, account []common.Address) (*AccessManagerRoleGrantedIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var accountRule []interface{} + for _, accountItem := range account { + accountRule = append(accountRule, accountItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleGranted", roleIdRule, accountRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleGrantedIterator{contract: _AccessManager.contract, event: "RoleGranted", logs: logs, sub: sub}, nil +} + +// WatchRoleGranted is a free log subscription operation binding the contract event 0xf98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf. +// +// Solidity: event RoleGranted(uint64 indexed roleId, address indexed account, uint32 delay, uint48 since, bool newMember) +func (_AccessManager *AccessManagerFilterer) WatchRoleGranted(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleGranted, roleId []uint64, account []common.Address) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var accountRule []interface{} + for _, accountItem := range account { + accountRule = append(accountRule, accountItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleGranted", roleIdRule, accountRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleGranted) + if err := _AccessManager.contract.UnpackLog(event, "RoleGranted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleGranted is a log parse operation binding the contract event 0xf98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf. +// +// Solidity: event RoleGranted(uint64 indexed roleId, address indexed account, uint32 delay, uint48 since, bool newMember) +func (_AccessManager *AccessManagerFilterer) ParseRoleGranted(log types.Log) (*AccessManagerRoleGranted, error) { + event := new(AccessManagerRoleGranted) + if err := _AccessManager.contract.UnpackLog(event, "RoleGranted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleGuardianChangedIterator is returned from FilterRoleGuardianChanged and is used to iterate over the raw logs and unpacked data for RoleGuardianChanged events raised by the AccessManager contract. +type AccessManagerRoleGuardianChangedIterator struct { + Event *AccessManagerRoleGuardianChanged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleGuardianChangedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGuardianChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleGuardianChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleGuardianChangedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleGuardianChangedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleGuardianChanged represents a RoleGuardianChanged event raised by the AccessManager contract. +type AccessManagerRoleGuardianChanged struct { + RoleId uint64 + Guardian uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleGuardianChanged is a free log retrieval operation binding the contract event 0x7a8059630b897b5de4c08ade69f8b90c3ead1f8596d62d10b6c4d14a0afb4ae2. +// +// Solidity: event RoleGuardianChanged(uint64 indexed roleId, uint64 indexed guardian) +func (_AccessManager *AccessManagerFilterer) FilterRoleGuardianChanged(opts *bind.FilterOpts, roleId []uint64, guardian []uint64) (*AccessManagerRoleGuardianChangedIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var guardianRule []interface{} + for _, guardianItem := range guardian { + guardianRule = append(guardianRule, guardianItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleGuardianChanged", roleIdRule, guardianRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleGuardianChangedIterator{contract: _AccessManager.contract, event: "RoleGuardianChanged", logs: logs, sub: sub}, nil +} + +// WatchRoleGuardianChanged is a free log subscription operation binding the contract event 0x7a8059630b897b5de4c08ade69f8b90c3ead1f8596d62d10b6c4d14a0afb4ae2. +// +// Solidity: event RoleGuardianChanged(uint64 indexed roleId, uint64 indexed guardian) +func (_AccessManager *AccessManagerFilterer) WatchRoleGuardianChanged(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleGuardianChanged, roleId []uint64, guardian []uint64) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var guardianRule []interface{} + for _, guardianItem := range guardian { + guardianRule = append(guardianRule, guardianItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleGuardianChanged", roleIdRule, guardianRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleGuardianChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleGuardianChanged", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleGuardianChanged is a log parse operation binding the contract event 0x7a8059630b897b5de4c08ade69f8b90c3ead1f8596d62d10b6c4d14a0afb4ae2. +// +// Solidity: event RoleGuardianChanged(uint64 indexed roleId, uint64 indexed guardian) +func (_AccessManager *AccessManagerFilterer) ParseRoleGuardianChanged(log types.Log) (*AccessManagerRoleGuardianChanged, error) { + event := new(AccessManagerRoleGuardianChanged) + if err := _AccessManager.contract.UnpackLog(event, "RoleGuardianChanged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleLabelIterator is returned from FilterRoleLabel and is used to iterate over the raw logs and unpacked data for RoleLabel events raised by the AccessManager contract. +type AccessManagerRoleLabelIterator struct { + Event *AccessManagerRoleLabel // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleLabelIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleLabel) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleLabel) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleLabelIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleLabelIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleLabel represents a RoleLabel event raised by the AccessManager contract. +type AccessManagerRoleLabel struct { + RoleId uint64 + Label string + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleLabel is a free log retrieval operation binding the contract event 0x1256f5b5ecb89caec12db449738f2fbcd1ba5806cf38f35413f4e5c15bf6a450. +// +// Solidity: event RoleLabel(uint64 indexed roleId, string label) +func (_AccessManager *AccessManagerFilterer) FilterRoleLabel(opts *bind.FilterOpts, roleId []uint64) (*AccessManagerRoleLabelIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleLabel", roleIdRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleLabelIterator{contract: _AccessManager.contract, event: "RoleLabel", logs: logs, sub: sub}, nil +} + +// WatchRoleLabel is a free log subscription operation binding the contract event 0x1256f5b5ecb89caec12db449738f2fbcd1ba5806cf38f35413f4e5c15bf6a450. +// +// Solidity: event RoleLabel(uint64 indexed roleId, string label) +func (_AccessManager *AccessManagerFilterer) WatchRoleLabel(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleLabel, roleId []uint64) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleLabel", roleIdRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleLabel) + if err := _AccessManager.contract.UnpackLog(event, "RoleLabel", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleLabel is a log parse operation binding the contract event 0x1256f5b5ecb89caec12db449738f2fbcd1ba5806cf38f35413f4e5c15bf6a450. +// +// Solidity: event RoleLabel(uint64 indexed roleId, string label) +func (_AccessManager *AccessManagerFilterer) ParseRoleLabel(log types.Log) (*AccessManagerRoleLabel, error) { + event := new(AccessManagerRoleLabel) + if err := _AccessManager.contract.UnpackLog(event, "RoleLabel", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerRoleRevokedIterator is returned from FilterRoleRevoked and is used to iterate over the raw logs and unpacked data for RoleRevoked events raised by the AccessManager contract. +type AccessManagerRoleRevokedIterator struct { + Event *AccessManagerRoleRevoked // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerRoleRevokedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleRevoked) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerRoleRevoked) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerRoleRevokedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerRoleRevokedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerRoleRevoked represents a RoleRevoked event raised by the AccessManager contract. +type AccessManagerRoleRevoked struct { + RoleId uint64 + Account common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRoleRevoked is a free log retrieval operation binding the contract event 0xf229baa593af28c41b1d16b748cd7688f0c83aaf92d4be41c44005defe84c166. +// +// Solidity: event RoleRevoked(uint64 indexed roleId, address indexed account) +func (_AccessManager *AccessManagerFilterer) FilterRoleRevoked(opts *bind.FilterOpts, roleId []uint64, account []common.Address) (*AccessManagerRoleRevokedIterator, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var accountRule []interface{} + for _, accountItem := range account { + accountRule = append(accountRule, accountItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "RoleRevoked", roleIdRule, accountRule) + if err != nil { + return nil, err + } + return &AccessManagerRoleRevokedIterator{contract: _AccessManager.contract, event: "RoleRevoked", logs: logs, sub: sub}, nil +} + +// WatchRoleRevoked is a free log subscription operation binding the contract event 0xf229baa593af28c41b1d16b748cd7688f0c83aaf92d4be41c44005defe84c166. +// +// Solidity: event RoleRevoked(uint64 indexed roleId, address indexed account) +func (_AccessManager *AccessManagerFilterer) WatchRoleRevoked(opts *bind.WatchOpts, sink chan<- *AccessManagerRoleRevoked, roleId []uint64, account []common.Address) (event.Subscription, error) { + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + var accountRule []interface{} + for _, accountItem := range account { + accountRule = append(accountRule, accountItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "RoleRevoked", roleIdRule, accountRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerRoleRevoked) + if err := _AccessManager.contract.UnpackLog(event, "RoleRevoked", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRoleRevoked is a log parse operation binding the contract event 0xf229baa593af28c41b1d16b748cd7688f0c83aaf92d4be41c44005defe84c166. +// +// Solidity: event RoleRevoked(uint64 indexed roleId, address indexed account) +func (_AccessManager *AccessManagerFilterer) ParseRoleRevoked(log types.Log) (*AccessManagerRoleRevoked, error) { + event := new(AccessManagerRoleRevoked) + if err := _AccessManager.contract.UnpackLog(event, "RoleRevoked", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerTargetAdminDelayUpdatedIterator is returned from FilterTargetAdminDelayUpdated and is used to iterate over the raw logs and unpacked data for TargetAdminDelayUpdated events raised by the AccessManager contract. +type AccessManagerTargetAdminDelayUpdatedIterator struct { + Event *AccessManagerTargetAdminDelayUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerTargetAdminDelayUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetAdminDelayUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetAdminDelayUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerTargetAdminDelayUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerTargetAdminDelayUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerTargetAdminDelayUpdated represents a TargetAdminDelayUpdated event raised by the AccessManager contract. +type AccessManagerTargetAdminDelayUpdated struct { + Target common.Address + Delay uint32 + Since *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTargetAdminDelayUpdated is a free log retrieval operation binding the contract event 0xa56b76017453f399ec2327ba00375dbfb1fd070ff854341ad6191e6a2e2de19c. +// +// Solidity: event TargetAdminDelayUpdated(address indexed target, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) FilterTargetAdminDelayUpdated(opts *bind.FilterOpts, target []common.Address) (*AccessManagerTargetAdminDelayUpdatedIterator, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "TargetAdminDelayUpdated", targetRule) + if err != nil { + return nil, err + } + return &AccessManagerTargetAdminDelayUpdatedIterator{contract: _AccessManager.contract, event: "TargetAdminDelayUpdated", logs: logs, sub: sub}, nil +} + +// WatchTargetAdminDelayUpdated is a free log subscription operation binding the contract event 0xa56b76017453f399ec2327ba00375dbfb1fd070ff854341ad6191e6a2e2de19c. +// +// Solidity: event TargetAdminDelayUpdated(address indexed target, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) WatchTargetAdminDelayUpdated(opts *bind.WatchOpts, sink chan<- *AccessManagerTargetAdminDelayUpdated, target []common.Address) (event.Subscription, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "TargetAdminDelayUpdated", targetRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerTargetAdminDelayUpdated) + if err := _AccessManager.contract.UnpackLog(event, "TargetAdminDelayUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTargetAdminDelayUpdated is a log parse operation binding the contract event 0xa56b76017453f399ec2327ba00375dbfb1fd070ff854341ad6191e6a2e2de19c. +// +// Solidity: event TargetAdminDelayUpdated(address indexed target, uint32 delay, uint48 since) +func (_AccessManager *AccessManagerFilterer) ParseTargetAdminDelayUpdated(log types.Log) (*AccessManagerTargetAdminDelayUpdated, error) { + event := new(AccessManagerTargetAdminDelayUpdated) + if err := _AccessManager.contract.UnpackLog(event, "TargetAdminDelayUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerTargetClosedIterator is returned from FilterTargetClosed and is used to iterate over the raw logs and unpacked data for TargetClosed events raised by the AccessManager contract. +type AccessManagerTargetClosedIterator struct { + Event *AccessManagerTargetClosed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerTargetClosedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetClosed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetClosed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerTargetClosedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerTargetClosedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerTargetClosed represents a TargetClosed event raised by the AccessManager contract. +type AccessManagerTargetClosed struct { + Target common.Address + Closed bool + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTargetClosed is a free log retrieval operation binding the contract event 0x90d4e7bb7e5d933792b3562e1741306f8be94837e1348dacef9b6f1df56eb138. +// +// Solidity: event TargetClosed(address indexed target, bool closed) +func (_AccessManager *AccessManagerFilterer) FilterTargetClosed(opts *bind.FilterOpts, target []common.Address) (*AccessManagerTargetClosedIterator, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "TargetClosed", targetRule) + if err != nil { + return nil, err + } + return &AccessManagerTargetClosedIterator{contract: _AccessManager.contract, event: "TargetClosed", logs: logs, sub: sub}, nil +} + +// WatchTargetClosed is a free log subscription operation binding the contract event 0x90d4e7bb7e5d933792b3562e1741306f8be94837e1348dacef9b6f1df56eb138. +// +// Solidity: event TargetClosed(address indexed target, bool closed) +func (_AccessManager *AccessManagerFilterer) WatchTargetClosed(opts *bind.WatchOpts, sink chan<- *AccessManagerTargetClosed, target []common.Address) (event.Subscription, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "TargetClosed", targetRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerTargetClosed) + if err := _AccessManager.contract.UnpackLog(event, "TargetClosed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTargetClosed is a log parse operation binding the contract event 0x90d4e7bb7e5d933792b3562e1741306f8be94837e1348dacef9b6f1df56eb138. +// +// Solidity: event TargetClosed(address indexed target, bool closed) +func (_AccessManager *AccessManagerFilterer) ParseTargetClosed(log types.Log) (*AccessManagerTargetClosed, error) { + event := new(AccessManagerTargetClosed) + if err := _AccessManager.contract.UnpackLog(event, "TargetClosed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AccessManagerTargetFunctionRoleUpdatedIterator is returned from FilterTargetFunctionRoleUpdated and is used to iterate over the raw logs and unpacked data for TargetFunctionRoleUpdated events raised by the AccessManager contract. +type AccessManagerTargetFunctionRoleUpdatedIterator struct { + Event *AccessManagerTargetFunctionRoleUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AccessManagerTargetFunctionRoleUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetFunctionRoleUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AccessManagerTargetFunctionRoleUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AccessManagerTargetFunctionRoleUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AccessManagerTargetFunctionRoleUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AccessManagerTargetFunctionRoleUpdated represents a TargetFunctionRoleUpdated event raised by the AccessManager contract. +type AccessManagerTargetFunctionRoleUpdated struct { + Target common.Address + Selector [4]byte + RoleId uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTargetFunctionRoleUpdated is a free log retrieval operation binding the contract event 0x9ea6790c7dadfd01c9f8b9762b3682607af2c7e79e05a9f9fdf5580dde949151. +// +// Solidity: event TargetFunctionRoleUpdated(address indexed target, bytes4 selector, uint64 indexed roleId) +func (_AccessManager *AccessManagerFilterer) FilterTargetFunctionRoleUpdated(opts *bind.FilterOpts, target []common.Address, roleId []uint64) (*AccessManagerTargetFunctionRoleUpdatedIterator, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.FilterLogs(opts, "TargetFunctionRoleUpdated", targetRule, roleIdRule) + if err != nil { + return nil, err + } + return &AccessManagerTargetFunctionRoleUpdatedIterator{contract: _AccessManager.contract, event: "TargetFunctionRoleUpdated", logs: logs, sub: sub}, nil +} + +// WatchTargetFunctionRoleUpdated is a free log subscription operation binding the contract event 0x9ea6790c7dadfd01c9f8b9762b3682607af2c7e79e05a9f9fdf5580dde949151. +// +// Solidity: event TargetFunctionRoleUpdated(address indexed target, bytes4 selector, uint64 indexed roleId) +func (_AccessManager *AccessManagerFilterer) WatchTargetFunctionRoleUpdated(opts *bind.WatchOpts, sink chan<- *AccessManagerTargetFunctionRoleUpdated, target []common.Address, roleId []uint64) (event.Subscription, error) { + + var targetRule []interface{} + for _, targetItem := range target { + targetRule = append(targetRule, targetItem) + } + + var roleIdRule []interface{} + for _, roleIdItem := range roleId { + roleIdRule = append(roleIdRule, roleIdItem) + } + + logs, sub, err := _AccessManager.contract.WatchLogs(opts, "TargetFunctionRoleUpdated", targetRule, roleIdRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AccessManagerTargetFunctionRoleUpdated) + if err := _AccessManager.contract.UnpackLog(event, "TargetFunctionRoleUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTargetFunctionRoleUpdated is a log parse operation binding the contract event 0x9ea6790c7dadfd01c9f8b9762b3682607af2c7e79e05a9f9fdf5580dde949151. +// +// Solidity: event TargetFunctionRoleUpdated(address indexed target, bytes4 selector, uint64 indexed roleId) +func (_AccessManager *AccessManagerFilterer) ParseTargetFunctionRoleUpdated(log types.Log) (*AccessManagerTargetFunctionRoleUpdated, error) { + event := new(AccessManagerTargetFunctionRoleUpdated) + if err := _AccessManager.contract.UnpackLog(event, "TargetFunctionRoleUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/e2e/internal/harness/environment/solidityibc/app.go b/e2e/internal/harness/environment/solidityibc/app.go new file mode 100644 index 000000000..6f55e57c6 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/app.go @@ -0,0 +1,438 @@ +package solidityibc + +import ( + "context" + "fmt" + "math/big" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/erc1967proxy" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics27account" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics27gmp" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ift" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/environment/solidityibc/accessmanager" +) + +// gmpPortID mirrors ICS27Lib.DEFAULT_PORT_ID in Solidity IBC v3: the port the +// ICS27 GMP app registers under on the ICS26 router. +const gmpPortID = "gmpport" + +// publicRole mirrors IBCRolesLib.PUBLIC_ROLE (type(uint64).max): a target +// function assigned this role may be called by any address. +const publicRole uint64 = 1<<64 - 1 + +// ics26RelayerSelectorNames are the ICS26 router functions the truthful Relayer +// submits. Mirrors IBCRolesLib.ics26RelayerSelectors so relaying can be opened to +// a non-admin signer without minting a bespoke role. +var ics26RelayerSelectorNames = []string{"recvPacket", "timeoutPacket", "ackPacket", "updateClient"} + +// DeployGMPApp deploys the ICS27 GMP application (account logic, GMP +// implementation, and initialized ERC1967 proxy) and registers it with the ICS26 +// router under the GMP port. authority must be the router's AccessManager admin. +func (s *Setup) DeployGMPApp( + ctx context.Context, + authority evm.Account, + router common.Address, + accessManager common.Address, +) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: %w", err) + } + + accountLogic, err := s.deploy(ctx, authority, "ICS27Account logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics27account.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + gmpLogic, err := s.deploy(ctx, authority, "ICS27GMP logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics27gmp.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + gmpABI, err := ics27gmp.ContractMetaData.GetAbi() + if err != nil || gmpABI == nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: read ICS27GMP ABI: %w", err) + } + initialization, err := gmpABI.Pack("initialize", router, accountLogic, accessManager) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: encode initialization: %w", err) + } + + gmpProxy, err := s.deploy(ctx, authority, "ICS27GMP proxy", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract(opts, s.backend, gmpLogic, initialization) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + routerContract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC register GMP: bind ICS26Router: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := routerContract.AddIBCApp0(opts, gmpPortID, gmpProxy) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC register GMP port %q: %w", gmpPortID, err) + } + if _, err := s.awaitMined(ctx, "register GMP port", tx); err != nil { + return common.Address{}, err + } + return gmpProxy, nil +} + +// DeployIFT deploys an upgradeable IFT token (implementation plus an initialized +// ERC1967 proxy). The token is Ownable: owner receives mint and +// registerIFTBridge authority and must be the deploying authority's address so +// the same account can administer it. gmp is the ICS27 GMP the token sends +// through. +func (s *Setup) DeployIFT( + ctx context.Context, + authority evm.Account, + owner common.Address, + gmp common.Address, + name string, + symbol string, +) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: %w", err) + } + + iftLogic, err := s.deploy(ctx, authority, "IFT logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ift.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + iftABI, err := ift.ContractMetaData.GetAbi() + if err != nil || iftABI == nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: read IFT ABI: %w", err) + } + initialization, err := iftABI.Pack("initialize", owner, name, symbol, gmp) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: encode initialization: %w", err) + } + + iftProxy, err := s.deploy(ctx, authority, "IFT proxy", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract(opts, s.backend, iftLogic, initialization) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + return iftProxy, nil +} + +// DeploySendCallConstructor deploys the EVM IFT send-call constructor used to +// build counterparty mint calldata during registerIFTBridge. +func (s *Setup) DeploySendCallConstructor(ctx context.Context, authority evm.Account) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy send-call constructor: %w", err) + } + constructorABI, bytecode, err := loadSendCallConstructorArtifact() + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy send-call constructor: %w", err) + } + return s.deploy(ctx, authority, "EVMIFTSendCallConstructor", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := bind.DeployContract(opts, constructorABI, bytecode, s.backend) + return address, transaction, deployErr + }) +} + +// RegisterIFTBridge points the IFT's bridge for clientID at the counterparty IFT +// address and send-call constructor. authority must be the IFT's AccessManager +// admin because registerIFTBridge is access-restricted. +func (s *Setup) RegisterIFTBridge( + ctx context.Context, + authority evm.Account, + iftAddress common.Address, + clientID string, + counterpartyIFT string, + sendCallConstructor common.Address, +) error { + contract, err := ift.NewContract(iftAddress, s.backend) + if err != nil { + return fmt.Errorf("solidity IBC register IFT bridge: bind IFT: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := contract.RegisterIFTBridge(opts, clientID, counterpartyIFT, sendCallConstructor) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return fmt.Errorf("solidity IBC register IFT bridge for client %q: %w", clientID, err) + } + _, err = s.awaitMined(ctx, "register IFT bridge "+clientID, tx) + return err +} + +// AuthorizePublicRelay opens the ICS26 router's relayer-restricted functions to +// any caller by assigning them the public role. This lets a non-admin Relayer +// signer submit recvPacket/ackPacket/timeoutPacket/updateClient. authority must +// be the router's AccessManager admin. +func (s *Setup) AuthorizePublicRelay( + ctx context.Context, + authority evm.Account, + accessManager common.Address, + router common.Address, +) error { + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil || routerABI == nil { + return fmt.Errorf("solidity IBC authorize public relay: read ICS26Router ABI: %w", err) + } + selectors := make([][4]byte, 0, len(ics26RelayerSelectorNames)) + for _, name := range ics26RelayerSelectorNames { + method, ok := routerABI.Methods[name] + if !ok { + return fmt.Errorf("solidity IBC authorize public relay: ICS26Router ABI has no %q method", name) + } + var selector [4]byte + copy(selector[:], method.ID) + selectors = append(selectors, selector) + } + + access, err := accessmanager.NewAccessManager(accessManager, s.backend) + if err != nil { + return fmt.Errorf("solidity IBC authorize public relay: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := access.SetTargetFunctionRole(opts, router, selectors, publicRole) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return fmt.Errorf("solidity IBC authorize public relay: %w", err) + } + _, err = s.awaitMined(ctx, "authorize public relay", tx) + return err +} + +// MintIFT mints amount of the IFT to holder. authority must be the IFT's +// AccessManager admin because mint is access-restricted. +func (s *Setup) MintIFT( + ctx context.Context, + authority evm.Account, + iftAddress common.Address, + holder common.Address, + amount *big.Int, +) error { + contract, err := ift.NewContract(iftAddress, s.backend) + if err != nil { + return fmt.Errorf("solidity IBC mint IFT: bind IFT: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := contract.Mint(opts, holder, amount) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return fmt.Errorf("solidity IBC mint IFT: %w", err) + } + _, err = s.awaitMined(ctx, "mint IFT", tx) + return err +} + +// IFTTransferResult reports the outcome of an iftTransfer send. +type IFTTransferResult struct { + Sequence uint64 + SourceTxHash common.Hash +} + +// IFTTransfer sends amount over clientID to receiver and returns the ICS26 packet +// sequence extracted from the router's SendPacket event. sender must hold the IFT +// balance and pay gas. +func (s *Setup) IFTTransfer( + ctx context.Context, + sender evm.Account, + iftAddress common.Address, + router common.Address, + clientID string, + receiver string, + amount *big.Int, + timeoutTimestamp uint64, +) (IFTTransferResult, error) { + contract, err := ift.NewContract(iftAddress, s.backend) + if err != nil { + return IFTTransferResult{}, fmt.Errorf("solidity IBC IFT transfer: bind IFT: %w", err) + } + _, tx, err := s.send(ctx, sender, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := contract.IftTransfer(opts, clientID, receiver, amount, timeoutTimestamp) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return IFTTransferResult{}, fmt.Errorf("solidity IBC IFT transfer: %w", err) + } + receipt, err := s.awaitMined(ctx, "IFT transfer", tx) + if err != nil { + return IFTTransferResult{}, err + } + + routerContract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return IFTTransferResult{}, fmt.Errorf("solidity IBC IFT transfer: bind ICS26Router: %w", err) + } + for _, log := range receipt.Logs { + if log == nil || log.Address != router { + continue + } + event, parseErr := routerContract.ParseSendPacket(*log) + if parseErr != nil { + continue + } + return IFTTransferResult{Sequence: event.Packet.Sequence, SourceTxHash: tx.Hash()}, nil + } + return IFTTransferResult{}, fmt.Errorf("solidity IBC IFT transfer: no SendPacket event in tx %s", tx.Hash()) +} + +// IFTBalanceOf reads the IFT balance of account. +func (s *Setup) IFTBalanceOf(ctx context.Context, iftAddress common.Address, account common.Address) (*big.Int, error) { + contract, err := ift.NewContractCaller(iftAddress, s.backend) + if err != nil { + return nil, fmt.Errorf("solidity IBC read IFT balance: bind IFT: %w", err) + } + balance, err := contract.BalanceOf(&bind.CallOpts{Context: ctx}, account) + if err != nil { + return nil, fmt.Errorf("solidity IBC read IFT balance: %w", err) + } + return balance, nil +} + +// GMPSendResult reports the outcome of an ICS27 GMP sendCall. +type GMPSendResult struct { + Sequence uint64 + SourceTxHash common.Hash +} + +// GMPSendCall submits an ICS27 GMP sendCall through the GMP proxy and returns the +// ICS26 packet sequence extracted from the router's SendPacket event. sender pays +// gas; receiver is the destination target contract address string; payload is the +// calldata the destination ICS27 account executes against receiver. The account +// salt and memo are left empty; add parameters when a test needs them. +func (s *Setup) GMPSendCall( + ctx context.Context, + sender evm.Account, + gmp common.Address, + router common.Address, + sourceClient string, + receiver string, + payload []byte, + timeoutTimestamp uint64, +) (GMPSendResult, error) { + contract, err := ics27gmp.NewContract(gmp, s.backend) + if err != nil { + return GMPSendResult{}, fmt.Errorf("solidity IBC GMP send call: bind ICS27GMP: %w", err) + } + _, tx, err := s.send(ctx, sender, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := contract.SendCall(opts, ics27gmp.IICS27GMPMsgsSendCallMsg{ + SourceClient: sourceClient, + Receiver: receiver, + Payload: payload, + TimeoutTimestamp: timeoutTimestamp, + }) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return GMPSendResult{}, fmt.Errorf("solidity IBC GMP send call: %w", err) + } + receipt, err := s.awaitMined(ctx, "GMP send call", tx) + if err != nil { + return GMPSendResult{}, err + } + + routerContract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return GMPSendResult{}, fmt.Errorf("solidity IBC GMP send call: bind ICS26Router: %w", err) + } + for _, log := range receipt.Logs { + if log == nil || log.Address != router { + continue + } + event, parseErr := routerContract.ParseSendPacket(*log) + if parseErr != nil { + continue + } + return GMPSendResult{Sequence: event.Packet.Sequence, SourceTxHash: tx.Hash()}, nil + } + return GMPSendResult{}, fmt.Errorf("solidity IBC GMP send call: no SendPacket event in tx %s", tx.Hash()) +} + +// GMPAccountAddress returns the deterministic ICS27 account address the GMP proxy +// derives on this chain for a packet arriving over destinationClient from sender +// with salt. It mirrors ICS27GMP.getOrComputeAccountAddress, so callers can +// pre-fund the account a delivered GMP call will act through. +func (s *Setup) GMPAccountAddress( + ctx context.Context, + gmp common.Address, + destinationClient string, + sender string, + salt []byte, +) (common.Address, error) { + caller, err := ics27gmp.NewContractCaller(gmp, s.backend) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC GMP account: bind ICS27GMP: %w", err) + } + accountID := ics27gmp.IICS27GMPMsgsAccountIdentifier{ + ClientId: destinationClient, + Sender: sender, + Salt: salt, + } + address, err := caller.GetOrComputeAccountAddress(&bind.CallOpts{Context: ctx}, accountID) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC GMP account: query address: %w", err) + } + return address, nil +} + +// IFTTransferCalldata packs the ERC20 transfer(recipient, amount) calldata for an +// IFT token so a GMP call can carry it as its destination payload. +func IFTTransferCalldata(recipient common.Address, amount *big.Int) ([]byte, error) { + iftABI, err := ift.ContractMetaData.GetAbi() + if err != nil || iftABI == nil { + return nil, fmt.Errorf("solidity IBC IFT transfer calldata: read IFT ABI: %w", err) + } + calldata, err := iftABI.Pack("transfer", recipient, amount) + if err != nil { + return nil, fmt.Errorf("solidity IBC IFT transfer calldata: encode transfer: %w", err) + } + return calldata, nil +} + +// deploy sends a contract-creation transaction, waits for it to mine, and +// verifies the deployed address matches the prediction. +func (s *Setup) deploy( + ctx context.Context, + authority evm.Account, + label string, + fn func(*bind.TransactOpts) (common.Address, *types.Transaction, error), +) (common.Address, error) { + address, tx, err := s.send(ctx, authority, fn) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy %s: %w", label, err) + } + receipt, err := s.awaitMined(ctx, "deploy "+label, tx) + if err != nil { + return common.Address{}, err + } + if err := requireDeploymentAddress(label, address, receipt); err != nil { + return common.Address{}, err + } + return address, nil +} diff --git a/e2e/internal/harness/environment/solidityibc/app_artifact.go b/e2e/internal/harness/environment/solidityibc/app_artifact.go new file mode 100644 index 000000000..ea23d5894 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/app_artifact.go @@ -0,0 +1,55 @@ +package solidityibc + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + _ "embed" +) + +// Solidity IBC v3 ships EVMIFTSendCallConstructor with its IFT utilities, but its +// Go bindings omit that contract's creation bytecode. The embedded artifact is a +// byte-for-byte behavioral equivalent built from the local contracts project. +// +//go:embed contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json +var sendCallConstructorArtifactJSON []byte + +func loadSendCallConstructorArtifact() (abi.ABI, []byte, error) { + return loadForgeArtifact("EVMIFTSendCallConstructor", sendCallConstructorArtifactJSON) +} + +// forgeArtifact is the subset of a Foundry build artifact this package reads: the +// contract ABI and its creation bytecode. +type forgeArtifact struct { + ABI json.RawMessage `json:"abi"` + Bytecode struct { + Object string `json:"object"` + } `json:"bytecode"` +} + +func loadForgeArtifact(label string, raw []byte) (abi.ABI, []byte, error) { + parsed, bytecode, err := decodeForgeArtifact(raw) + if err != nil { + return abi.ABI{}, nil, fmt.Errorf("%s artifact: %w", label, err) + } + if len(bytecode) == 0 { + return abi.ABI{}, nil, fmt.Errorf("%s artifact has empty creation bytecode", label) + } + return parsed, bytecode, nil +} + +func decodeForgeArtifact(raw []byte) (abi.ABI, []byte, error) { + var artifact forgeArtifact + if err := json.Unmarshal(raw, &artifact); err != nil { + return abi.ABI{}, nil, fmt.Errorf("decode artifact: %w", err) + } + parsed, err := abi.JSON(strings.NewReader(string(artifact.ABI))) + if err != nil { + return abi.ABI{}, nil, fmt.Errorf("parse ABI: %w", err) + } + return parsed, common.FromHex(artifact.Bytecode.Object), nil +} diff --git a/e2e/internal/harness/environment/solidityibc/contracts/.gitignore b/e2e/internal/harness/environment/solidityibc/contracts/.gitignore new file mode 100644 index 000000000..b1dc02df3 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/cache/ +/out/ diff --git a/e2e/internal/harness/environment/solidityibc/contracts/bun.lock b/e2e/internal/harness/environment/solidityibc/contracts/bun.lock new file mode 100644 index 000000000..d6dd0748b --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@ibc-link/solidity-ibc-contract-artifacts", + "dependencies": { + "@openzeppelin/contracts": "5.6.1", + }, + }, + }, + "packages": { + "@openzeppelin/contracts": ["@openzeppelin/contracts@5.6.1", "", {}, "sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w=="], + } +} diff --git a/e2e/internal/harness/environment/solidityibc/contracts/foundry.toml b/e2e/internal/harness/environment/solidityibc/contracts/foundry.toml new file mode 100644 index 000000000..f9fe8c895 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +out = "out" +libs = ["node_modules"] +remappings = ["@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/"] +auto_detect_solc = false +solc = "0.8.28" +evm_version = "cancun" +bytecode_hash = "none" +optimizer = true +optimizer_runs = 10000 +via_ir = true diff --git a/e2e/internal/harness/environment/solidityibc/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json b/e2e/internal/harness/environment/solidityibc/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json new file mode 100644 index 000000000..f1d0f377f --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"constructMintCall","inputs":[{"name":"receiver","type":"string","internalType":"string"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bytes","internalType":"bytes"}],"stateMutability":"pure"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"error","name":"EVMIFTInvalidReceiver","inputs":[{"name":"receiver","type":"string","internalType":"string"}]}],"bytecode":{"object":"0x608080604052346015576105f0908161001a8239f35b5f80fdfe6080806040526004361015610012575f80fd5b5f3560e01c90816301ffc9a7146101f657506356d981a714610032575f80fd5b346101f25760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f25760043567ffffffffffffffff81116101f257366023820112156101f257806004013567ffffffffffffffff81116101f257602482019160248236920101116101f2577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f820116916100f76040516100dd60208601826102b2565b838152838360208301375f60208583010152805190610320565b9390156101a657836040805173ffffffffffffffffffffffffffffffffffffffff60208201937f0a7244e700000000000000000000000000000000000000000000000000000000855216602482015260243560448201526044815261015d6064826102b2565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8351948593602085525180918160208701528686015e5f85828601015201168101030190f35b906044915f83856040519687957fddd8c4b60000000000000000000000000000000000000000000000000000000087526020600488015281602488015283870137840101528101030190fd5b5f80fd5b346101f25760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f257600435907fffffffff0000000000000000000000000000000000000000000000000000000082168092036101f257817f56d981a70000000000000000000000000000000000000000000000000000000060209314908115610288575b5015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501483610281565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176102f357604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b805182118015610409575b6103855760018211806103ba575b158015908160011b918204600214171561038d576028018060281161038d5782036103855773ffffffffffffffffffffffffffffffffffffffff92915f61037f92610410565b90921690565b50505f905f90565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b507f30780000000000000000000000000000000000000000000000000000000000007fffff00000000000000000000000000000000000000000000000000000000000060208301511614610339565b505f61032b565b9290926001840180851161038d578311806104c6575b15938415948560011b958604600214171561038d575f94810180911161038d579192905b81831061045a5750505060019190565b9092919360ff6104917fff000000000000000000000000000000000000000000000000000000000000006020888601015116610517565b16600f81116104bb578160041b918083046010149015171561038d5760019101940191929061044a565b505f94508493505050565b507f30780000000000000000000000000000000000000000000000000000000000007fffff000000000000000000000000000000000000000000000000000000000000602086840101511614610426565b60f81c602f8111806105d9575b15610551577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00160ff1690565b60608111806105cf575b15610588577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa90160ff1690565b60408111806105c5575b156105bf577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc90160ff1690565b5060ff90565b5060478110610592565b506067811061055b565b50603a811061052456fea164736f6c634300081c000a","sourceMap":"1075:684:19:-:0;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x6080806040526004361015610012575f80fd5b5f3560e01c90816301ffc9a7146101f657506356d981a714610032575f80fd5b346101f25760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f25760043567ffffffffffffffff81116101f257366023820112156101f257806004013567ffffffffffffffff81116101f257602482019160248236920101116101f2577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f820116916100f76040516100dd60208601826102b2565b838152838360208301375f60208583010152805190610320565b9390156101a657836040805173ffffffffffffffffffffffffffffffffffffffff60208201937f0a7244e700000000000000000000000000000000000000000000000000000000855216602482015260243560448201526044815261015d6064826102b2565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8351948593602085525180918160208701528686015e5f85828601015201168101030190f35b906044915f83856040519687957fddd8c4b60000000000000000000000000000000000000000000000000000000087526020600488015281602488015283870137840101528101030190fd5b5f80fd5b346101f25760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f257600435907fffffffff0000000000000000000000000000000000000000000000000000000082168092036101f257817f56d981a70000000000000000000000000000000000000000000000000000000060209314908115610288575b5015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501483610281565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176102f357604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b805182118015610409575b6103855760018211806103ba575b158015908160011b918204600214171561038d576028018060281161038d5782036103855773ffffffffffffffffffffffffffffffffffffffff92915f61037f92610410565b90921690565b50505f905f90565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b507f30780000000000000000000000000000000000000000000000000000000000007fffff00000000000000000000000000000000000000000000000000000000000060208301511614610339565b505f61032b565b9290926001840180851161038d578311806104c6575b15938415948560011b958604600214171561038d575f94810180911161038d579192905b81831061045a5750505060019190565b9092919360ff6104917fff000000000000000000000000000000000000000000000000000000000000006020888601015116610517565b16600f81116104bb578160041b918083046010149015171561038d5760019101940191929061044a565b505f94508493505050565b507f30780000000000000000000000000000000000000000000000000000000000007fffff000000000000000000000000000000000000000000000000000000000000602086840101511614610426565b60f81c602f8111806105d9575b15610551577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00160ff1690565b60608111806105cf575b15610588577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa90160ff1690565b60408111806105c5575b156105bf577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc90160ff1690565b5060ff90565b5060478110610592565b506067811061055b565b50603a811061052456fea164736f6c634300081c000a","sourceMap":"1075:684:19:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;15468:46:10;1075:684:19;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;15468:46:10;;:::i;:::-;1075:684:19;;;;;;;;;;;1467:58;;;;;;1075:684;;1467:58;;1075:684;;;;;;;;1467:58;;;;;;:::i;:::-;1075:684;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;1654:56;1669:41;1075:684;1654:56;;:96;;;;;1075:684;;;;;;;1654:96;844:25:12;829:40;;;1654:96:19;;;1075:684;;;;;;;;;;;;;;;;;;;;:::o;:::-;;-1:-1:-1;1075:684:19;;;;;-1:-1:-1;1075:684:19;15758:889:10;1075:684:19;;15928:25:10;;:40;;;;15758:889;15924:72;;16039:1;16025:15;;16024:88;;;15758:889;34860:71:15;;;1075:684:19;;16039:1:10;1075:684:19;;;;16244:1:10;1075:684:19;;;;;16218:2:10;1075:684:19;;16218:2:10;1075:684:19;;;16310:29:10;;;;1075:684:19;16478:50:10;;-1:-1:-1;16478:50:10;;;:::i;:::-;16542:31;;1075:684:19;;16542:31:10:o;16306:335::-;16604:26;;-1:-1:-1;16604:26:10;-1:-1:-1;16604:26:10;:::o;1075:684:19:-;;-1:-1:-1;1075:684:19;;;;;-1:-1:-1;1075:684:19;16024:88:10;20826:95;1075:684:19;;20826:95:10;;;;1075:684:19;16045:67:10;16024:88;;15928:40;;1075:684:19;15928:40:10;;13189:1052;;;;13484:1;1075:684:19;;;;;;;13470:15:10;;13469:82;;;13189:1052;34860:71:15;;;;1075:684:19;;13484:1:10;1075:684:19;;;;13670:1:10;1075:684:19;;;;;-1:-1:-1;13727:14:10;1075:684:19;;;;;;;13715:26:10;;;13743:7;;;;;;14213:21;;;13484:1;14213:21;13189:1052;:::o;13752:3::-;20826:95;;;;1075:684:19;13783:55:10;1075:684:19;20826:95:10;;;;;;1075:684:19;13783:55:10;:::i;:::-;1075:684:19;13862:2:10;13856:8;;13852:31;;1075:684:19;;;;;;;13907:2:10;1075:684:19;;;;;;;13484:1:10;1075:684:19;;13752:3:10;1075:684:19;13715:26:10;;;;;13852:31;-1:-1:-1;;;;;;;;;13866:17:10:o;13469:82::-;20826:95;1075:684:19;;20826:95:10;;;;;;1075:684:19;13490:61:10;13469:82;;16653:524;1075:684:19;;16946:2:10;16938:10;;:24;;;16653:524;16934:203;;;1075:684:19;;;;16653:524:10;:::o;16934:203::-;17006:2;16998:10;;:25;;;16934:203;16994:143;;;1075:684:19;;;;;16653:524:10:o;16994:143::-;17067:2;17059:10;;:24;;;16994:143;17055:82;;;1075:684:19;;;;16653:524:10;:::o;17055:82::-;17115:22;1075:684:19;17115:22:10;:::o;17059:24::-;17073:10;17081:2;17073:10;;17059:24;;16998:25;17012:11;17020:3;17012:11;;16998:25;;16938:24;16952:10;16960:2;16952:10;;16938:24;","linkReferences":{}},"methodIdentifiers":{"constructMintCall(string,uint256)":"56d981a7","supportsInterface(bytes4)":"01ffc9a7"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.28+commit.7893614a\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"receiver\",\"type\":\"string\"}],\"name\":\"EVMIFTInvalidReceiver\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"string\",\"name\":\"receiver\",\"type\":\"string\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"constructMintCall\",\"outputs\":[{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{\"supportsInterface(bytes4)\":{\"details\":\"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] to learn more about how these ids are created. This function call must use less than 30 000 gas.\"}},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"src/EVMIFTSendCallConstructor.sol\":\"EVMIFTSendCallConstructor\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"none\"},\"optimizer\":{\"enabled\":true,\"runs\":10000},\"remappings\":[\":@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/\"],\"viaIR\":true},\"sources\":{\"node_modules/@openzeppelin/contracts/utils/Bytes.sol\":{\"keccak256\":\"0xf80e4b96b6849936ea0fabd76cf81b13d7276295fddb1e1c07afb3ddf650b0ac\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://6430007255ac11c6e9c4331a418f3af52ff66fbbae959f645301d025b20aeb08\",\"dweb:/ipfs/QmQE5fjmBBRw9ndnzJuC2uskYss1gbaR7JdazoCf1FGoBj\"]},\"node_modules/@openzeppelin/contracts/utils/Panic.sol\":{\"keccak256\":\"0xf7fe324703a64fc51702311dc51562d5cb1497734f074e4f483bfb6717572d7a\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c6a5ff4f9fd8649b7ee20800b7fa387d3465bd77cf20c2d1068cd5c98e1ed57a\",\"dweb:/ipfs/QmVSaVJf9FXFhdYEYeCEfjMVHrxDh5qL4CGkxdMWpQCrqG\"]},\"node_modules/@openzeppelin/contracts/utils/Strings.sol\":{\"keccak256\":\"0x1fdc2de9585ab0f1c417f02a873a2b6343cd64bb7ffec6b00ffa11a4a158d9e8\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://1ab223a3d969c6cd7610049431dd42740960c477e45878f5d8b54411f7a32bdc\",\"dweb:/ipfs/QmWumhjvhpKcwx3vDPQninsNVTc3BGvqaihkQakFkQYpaS\"]},\"node_modules/@openzeppelin/contracts/utils/introspection/ERC165.sol\":{\"keccak256\":\"0x2d9dc2fe26180f74c11c13663647d38e259e45f95eb88f57b61d2160b0109d3e\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://81233d1f98060113d9922180bb0f14f8335856fe9f339134b09335e9f678c377\",\"dweb:/ipfs/QmWh6R35SarhAn4z2wH8SU456jJSYL2FgucfTFgbHJJN4E\"]},\"node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol\":{\"keccak256\":\"0x8891738ffe910f0cf2da09566928589bf5d63f4524dd734fd9cedbac3274dd5c\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://971f954442df5c2ef5b5ebf1eb245d7105d9fbacc7386ee5c796df1d45b21617\",\"dweb:/ipfs/QmadRjHbkicwqwwh61raUEapaVEtaLMcYbQZWs9gUkgj3u\"]},\"node_modules/@openzeppelin/contracts/utils/math/Math.sol\":{\"keccak256\":\"0x59973a93b1f983f94e10529f46ca46544a9fec2b5f56fb1390bbe9e0dc79f857\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://c55ef8f0b9719790c2ae6f81d80a59ffb89f2f0abe6bc065585bd79edce058c5\",\"dweb:/ipfs/QmNNmCbX9NjPXKqHJN8R1WC2rNeZSQrPZLd6FF6AKLyH5Y\"]},\"node_modules/@openzeppelin/contracts/utils/math/SafeCast.sol\":{\"keccak256\":\"0xc8cae21c9ae4a46e5162ff9bf5b351d6fa6a6eba72d515f3bc1bdfeda7fdf083\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://ce830ebcf28e31643caba318996db3763c36d52cd0f23798ba83c135355d45e9\",\"dweb:/ipfs/QmdGPcvptHN7UBCbUYBbRX3hiRVRFLRwno8b4uga6uFNif\"]},\"node_modules/@openzeppelin/contracts/utils/math/SignedMath.sol\":{\"keccak256\":\"0xb1970fac7b64e6c09611e6691791e848d5e3fe410fa5899e7df2e0afd77a99e3\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://db5fbb3dddd8b7047465b62575d96231ba8a2774d37fb4737fbf23340fabbb03\",\"dweb:/ipfs/QmVUSvooZKEdEdap619tcJjTLcAuH6QBdZqAzWwnAXZAWJ\"]},\"src/EVMIFTSendCallConstructor.sol\":{\"keccak256\":\"0x4182f002743a34e4e506d5d2f25b50bbaf08c38ffb619739784a0ec77e649ab9\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://284a941311d92a48773e2ba010a657c5c4bb0015de9889a36b4f9956b2a084d0\",\"dweb:/ipfs/QmY3F8C63b7iujxXPVm8Tof3XJfDiLMQs4As4JiWCqjDDu\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.28+commit.7893614a"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"string","name":"receiver","type":"string"}],"type":"error","name":"EVMIFTInvalidReceiver"},{"inputs":[{"internalType":"string","name":"receiver","type":"string"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"pure","type":"function","name":"constructMintCall","outputs":[{"internalType":"bytes","name":"","type":"bytes"}]},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"stateMutability":"view","type":"function","name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{"supportsInterface(bytes4)":{"details":"Returns true if this contract implements the interface defined by `interfaceId`. See the corresponding https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] to learn more about how these ids are created. This function call must use less than 30 000 gas."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/"],"optimizer":{"enabled":true,"runs":10000},"metadata":{"bytecodeHash":"none"},"compilationTarget":{"src/EVMIFTSendCallConstructor.sol":"EVMIFTSendCallConstructor"},"evmVersion":"cancun","libraries":{},"viaIR":true},"sources":{"node_modules/@openzeppelin/contracts/utils/Bytes.sol":{"keccak256":"0xf80e4b96b6849936ea0fabd76cf81b13d7276295fddb1e1c07afb3ddf650b0ac","urls":["bzz-raw://6430007255ac11c6e9c4331a418f3af52ff66fbbae959f645301d025b20aeb08","dweb:/ipfs/QmQE5fjmBBRw9ndnzJuC2uskYss1gbaR7JdazoCf1FGoBj"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/Panic.sol":{"keccak256":"0xf7fe324703a64fc51702311dc51562d5cb1497734f074e4f483bfb6717572d7a","urls":["bzz-raw://c6a5ff4f9fd8649b7ee20800b7fa387d3465bd77cf20c2d1068cd5c98e1ed57a","dweb:/ipfs/QmVSaVJf9FXFhdYEYeCEfjMVHrxDh5qL4CGkxdMWpQCrqG"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/Strings.sol":{"keccak256":"0x1fdc2de9585ab0f1c417f02a873a2b6343cd64bb7ffec6b00ffa11a4a158d9e8","urls":["bzz-raw://1ab223a3d969c6cd7610049431dd42740960c477e45878f5d8b54411f7a32bdc","dweb:/ipfs/QmWumhjvhpKcwx3vDPQninsNVTc3BGvqaihkQakFkQYpaS"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/introspection/ERC165.sol":{"keccak256":"0x2d9dc2fe26180f74c11c13663647d38e259e45f95eb88f57b61d2160b0109d3e","urls":["bzz-raw://81233d1f98060113d9922180bb0f14f8335856fe9f339134b09335e9f678c377","dweb:/ipfs/QmWh6R35SarhAn4z2wH8SU456jJSYL2FgucfTFgbHJJN4E"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x8891738ffe910f0cf2da09566928589bf5d63f4524dd734fd9cedbac3274dd5c","urls":["bzz-raw://971f954442df5c2ef5b5ebf1eb245d7105d9fbacc7386ee5c796df1d45b21617","dweb:/ipfs/QmadRjHbkicwqwwh61raUEapaVEtaLMcYbQZWs9gUkgj3u"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/math/Math.sol":{"keccak256":"0x59973a93b1f983f94e10529f46ca46544a9fec2b5f56fb1390bbe9e0dc79f857","urls":["bzz-raw://c55ef8f0b9719790c2ae6f81d80a59ffb89f2f0abe6bc065585bd79edce058c5","dweb:/ipfs/QmNNmCbX9NjPXKqHJN8R1WC2rNeZSQrPZLd6FF6AKLyH5Y"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/math/SafeCast.sol":{"keccak256":"0xc8cae21c9ae4a46e5162ff9bf5b351d6fa6a6eba72d515f3bc1bdfeda7fdf083","urls":["bzz-raw://ce830ebcf28e31643caba318996db3763c36d52cd0f23798ba83c135355d45e9","dweb:/ipfs/QmdGPcvptHN7UBCbUYBbRX3hiRVRFLRwno8b4uga6uFNif"],"license":"MIT"},"node_modules/@openzeppelin/contracts/utils/math/SignedMath.sol":{"keccak256":"0xb1970fac7b64e6c09611e6691791e848d5e3fe410fa5899e7df2e0afd77a99e3","urls":["bzz-raw://db5fbb3dddd8b7047465b62575d96231ba8a2774d37fb4737fbf23340fabbb03","dweb:/ipfs/QmVUSvooZKEdEdap619tcJjTLcAuH6QBdZqAzWwnAXZAWJ"],"license":"MIT"},"src/EVMIFTSendCallConstructor.sol":{"keccak256":"0x4182f002743a34e4e506d5d2f25b50bbaf08c38ffb619739784a0ec77e649ab9","urls":["bzz-raw://284a941311d92a48773e2ba010a657c5c4bb0015de9889a36b4f9956b2a084d0","dweb:/ipfs/QmY3F8C63b7iujxXPVm8Tof3XJfDiLMQs4As4JiWCqjDDu"],"license":"MIT"}},"version":1},"id":19} \ No newline at end of file diff --git a/e2e/internal/harness/environment/solidityibc/contracts/package.json b/e2e/internal/harness/environment/solidityibc/contracts/package.json new file mode 100644 index 000000000..7d5a6bb31 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/package.json @@ -0,0 +1,7 @@ +{ + "name": "@ibc-link/solidity-ibc-contract-artifacts", + "private": true, + "dependencies": { + "@openzeppelin/contracts": "5.6.1" + } +} diff --git a/e2e/internal/harness/environment/solidityibc/contracts/src/Dependencies.sol b/e2e/internal/harness/environment/solidityibc/contracts/src/Dependencies.sol new file mode 100644 index 000000000..34d224133 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/src/Dependencies.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// Solidity IBC v3.0.2 deploys OpenZeppelin's AccessManager directly, but its Go +// bindings do not include that contract's creation bytecode. Importing the +// exact OpenZeppelin release pinned by Solidity IBC produces the one missing +// artifact; the Solidity IBC contracts themselves come from the pinned upstream Go +// bindings. +import {AccessManager} from "@openzeppelin/contracts/access/manager/AccessManager.sol"; diff --git a/e2e/internal/harness/environment/solidityibc/contracts/src/EVMIFTSendCallConstructor.sol b/e2e/internal/harness/environment/solidityibc/contracts/src/EVMIFTSendCallConstructor.sol new file mode 100644 index 000000000..b656799af --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/contracts/src/EVMIFTSendCallConstructor.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// Solidity IBC v3 ships EVMIFTSendCallConstructor as part of its IFT utilities, +// but its Go bindings do not include that contract's creation bytecode. This is a +// local behavioral equivalent (not a byte-for-byte reproduction of the upstream +// contract or its exact declarations) covering only the two behaviors the IFT +// contract exercises: it reports support for IIFTSendCallConstructor (checked by +// registerIFTBridge via ERC165) and it produces the iftMint(address,uint256) +// calldata the counterparty IFT executes on receive. Only the function selectors +// need to match — the ERC165 interface id and the encoded call depend on them and +// not on state mutability — so the local constructMintCall is declared `pure` +// even though the upstream interface declares it `view` and inherits IERC165. + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +interface IIFTSendCallConstructor { + function constructMintCall(string calldata receiver, uint256 amount) external pure returns (bytes memory); +} + +interface IIFTMinter { + function iftMint(address receiver, uint256 amount) external; +} + +contract EVMIFTSendCallConstructor is IIFTSendCallConstructor, ERC165 { + error EVMIFTInvalidReceiver(string receiver); + + function constructMintCall(string calldata receiver, uint256 amount) external pure returns (bytes memory) { + (bool success, address receiverAddr) = Strings.tryParseAddress(receiver); + require(success, EVMIFTInvalidReceiver(receiver)); + + return abi.encodeCall(IIFTMinter.iftMint, (receiverAddr, amount)); + } + + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165) returns (bool) { + return interfaceId == type(IIFTSendCallConstructor).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/e2e/internal/harness/environment/solidityibc/setup.go b/e2e/internal/harness/environment/solidityibc/setup.go new file mode 100644 index 000000000..04fb8eea7 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/setup.go @@ -0,0 +1,587 @@ +package solidityibc + +import ( + "context" + "fmt" + "math/big" + "slices" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/attestation" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/erc1967proxy" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/environment/solidityibc/accessmanager" +) + +type contractBackend interface { + bind.ContractBackend + bind.DeployBackend +} + +// Setup performs Solidity IBC setup transactions on one EVM Chain. It borrows the +// client and never closes it. +type Setup struct { + backend contractBackend + chainID *big.Int +} + +// NewSetup binds Solidity IBC realization to an already-connected EVM client. +func NewSetup(ctx context.Context, client *ethclient.Client) (*Setup, error) { + if client == nil { + return nil, fmt.Errorf("solidity IBC setup: nil EVM client") + } + chainID, err := client.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("solidity IBC setup: query chain id: %w", err) + } + return newSetup(client, chainID) +} + +func newSetup(backend contractBackend, chainID *big.Int) (*Setup, error) { + if backend == nil { + return nil, fmt.Errorf("solidity IBC setup: nil contract backend") + } + if chainID == nil || chainID.Sign() <= 0 { + return nil, fmt.Errorf("solidity IBC setup: invalid EVM chain id") + } + return &Setup{backend: backend, chainID: new(big.Int).Set(chainID)}, nil +} + +// DeployInstance deploys AccessManager, ICS26Router implementation, and an +// initialized ERC1967 proxy in dependency order. +func (s *Setup) DeployInstance( + ctx context.Context, + authority evm.Account, +) (Instance, error) { + if authorityErr := validateAuthority(authority); authorityErr != nil { + return Instance{}, fmt.Errorf("solidity IBC deploy Instance: %w", authorityErr) + } + + accessAddress, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := accessmanager.DeployAccessManager( + opts, + s.backend, + authority.Address(), + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC deploy AccessManager: %w", err) + } + accessReceipt, err := s.awaitMined(ctx, "deploy AccessManager", tx) + if err != nil { + return Instance{}, err + } + if deploymentErr := requireDeploymentAddress("AccessManager", accessAddress, accessReceipt); deploymentErr != nil { + return Instance{}, deploymentErr + } + if adminErr := s.requireAdmin(ctx, accessAddress, authority.Address()); adminErr != nil { + return Instance{}, fmt.Errorf("solidity IBC verify AccessManager: %w", adminErr) + } + + routerImplementation, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics26router.DeployContract(opts, s.backend) + return address, transaction, deployErr + }, + ) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC deploy ICS26Router implementation: %w", err) + } + implementationReceipt, err := s.awaitMined( + ctx, + "deploy ICS26Router implementation", + tx, + ) + if err != nil { + return Instance{}, err + } + if deploymentErr := requireDeploymentAddress( + "ICS26Router implementation", + routerImplementation, + implementationReceipt, + ); deploymentErr != nil { + return Instance{}, deploymentErr + } + + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: %w", err) + } + if routerABI == nil { + return Instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: upstream binding has no ABI") + } + initialization, err := routerABI.Pack("initialize", accessAddress) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: %w", err) + } + + routerProxy, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract( + opts, + s.backend, + routerImplementation, + initialization, + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC deploy initialized ICS26Router proxy: %w", err) + } + proxyReceipt, err := s.awaitMined(ctx, "deploy initialized ICS26Router proxy", tx) + instance := Instance{AccessManager: accessAddress, Router: routerProxy} + if err != nil { + return Instance{}, err + } + if err := requireDeploymentAddress("ICS26Router proxy", routerProxy, proxyReceipt); err != nil { + return Instance{}, err + } + if _, err := s.AttachInstance(ctx, routerProxy); err != nil { + return Instance{}, fmt.Errorf("solidity IBC verify deployed Instance: %w", err) + } + return instance, nil +} + +// AttachInstance resolves the AccessManager from the router locator and +// verifies that both addresses contain the expected contract interfaces. +func (s *Setup) AttachInstance( + ctx context.Context, + routerAddress common.Address, +) (Instance, error) { + if routerAddress == (common.Address{}) { + return Instance{}, fmt.Errorf("solidity IBC attach Instance: zero ICS26Router address") + } + if err := s.requireCode(ctx, "ICS26Router", routerAddress); err != nil { + return Instance{}, err + } + router, err := ics26router.NewContract(routerAddress, s.backend) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC attach Instance: bind ICS26Router: %w", err) + } + authority, err := router.Authority(&bind.CallOpts{Context: ctx}) + if err != nil { + return Instance{}, fmt.Errorf("solidity IBC attach Instance: query ICS26Router authority: %w", err) + } + if authority == (common.Address{}) { + return Instance{}, fmt.Errorf("solidity IBC attach Instance: ICS26Router has a zero authority") + } + if err := s.requireCode(ctx, "AccessManager", authority); err != nil { + return Instance{}, err + } + if err := s.requireAccessManager(ctx, authority); err != nil { + return Instance{}, fmt.Errorf( + "solidity IBC attach Instance: authority %s is not an AccessManager: %w", + authority, + err, + ) + } + return Instance{AccessManager: authority, Router: routerAddress}, nil +} + +// PreparedClient is a validated, side-effect-free Client deployment. It keeps +// preparation facts private so deployment cannot bypass the graph-wide +// preflight performed by environment realization. +type PreparedClient struct { + setup *Setup + authority evm.Account + instance Instance + config AttestationClientConfig +} + +// PrepareClient validates one Client deployment without submitting a +// transaction. The returned value owns the snapshotted inputs used by Deploy. +func (s *Setup) PrepareClient( + ctx context.Context, + authority evm.Account, + router common.Address, + config AttestationClientConfig, +) (*PreparedClient, error) { + config = config.snapshot() + if err := config.validate(); err != nil { + return nil, fmt.Errorf("solidity IBC prepare Client: %w", err) + } + if err := validateAuthority(authority); err != nil { + return nil, fmt.Errorf("solidity IBC prepare Client: %w", err) + } + instance, err := s.AttachInstance(ctx, router) + if err != nil { + return nil, fmt.Errorf("solidity IBC prepare Client: %w", err) + } + if err := s.requireCanAddCustomClient(ctx, instance, authority.Address()); err != nil { + return nil, fmt.Errorf("solidity IBC prepare Client: authority cannot register it: %w", err) + } + if err := s.verifyClientVacant(ctx, instance, config.ID); err != nil { + return nil, fmt.Errorf("solidity IBC prepare Client: %w", err) + } + return &PreparedClient{setup: s, authority: authority, instance: instance, config: config}, nil +} + +// Deploy submits a prepared Client deployment and registers it with the router. +func (p *PreparedClient) Deploy(ctx context.Context) (Client, error) { + if p == nil || p.setup == nil { + return Client{}, fmt.Errorf("solidity IBC deploy Client: preparation is required") + } + s := p.setup + authority := p.authority + instance := p.instance + config := p.config + + clientAddress, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := attestation.DeployContract( + opts, + s.backend, + config.Attestors, + config.MinRequiredSignatures, + config.InitialHeight, + config.InitialTimestamp, + config.RoleManager, + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC deploy attestation Client %q: %w", config.ID, err) + } + clientReceipt, err := s.awaitMined( + ctx, + "deploy attestation Client "+config.ID, + tx, + ) + if err != nil { + return Client{}, err + } + if deploymentErr := requireDeploymentAddress( + "attestation Client "+config.ID, + clientAddress, + clientReceipt, + ); deploymentErr != nil { + return Client{}, deploymentErr + } + + router, err := ics26router.NewContract(instance.Router, s.backend) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC register Client %q: bind ICS26Router: %w", config.ID, err) + } + _, tx, err = s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := router.AddClient(opts, config.ID, ics26router.IICS02ClientMsgsCounterpartyInfo{ + ClientId: config.CounterpartyClientID, + MerklePrefix: [][]byte{{}}, + }, clientAddress) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC register Client %q: %w", config.ID, err) + } + _, err = s.awaitMined(ctx, "register Client "+config.ID, tx) + if err != nil { + return Client{}, err + } + + client, err := s.verifyClient(ctx, instance, config.ID, clientAddress, config.CounterpartyClientID) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC verify deployed Client %q: %w", config.ID, err) + } + return client, nil +} + +// verifyClientVacant proves that a custom Client ID is currently unoccupied. +// An unexpected read failure is not treated as vacancy. +func (s *Setup) verifyClientVacant(ctx context.Context, instance Instance, clientID string) error { + if !validCustomClientID(clientID) { + return fmt.Errorf("client id %q is not a valid Solidity IBC custom client identifier", clientID) + } + router, err := ics26router.NewContract(instance.Router, s.backend) + if err != nil { + return fmt.Errorf("verify Client %q vacancy: bind ICS26Router: %w", clientID, err) + } + registered, err := router.GetClient(&bind.CallOpts{Context: ctx}, clientID) + if err == nil { + return fmt.Errorf("Client %q is already registered at %s", clientID, registered) + } + if isIBCClientNotFound(err) { + return nil + } + return fmt.Errorf("verify Client %q vacancy: query ICS26Router: %w", clientID, err) +} + +// AttachClient discovers the light-client address from the router, verifies +// the reciprocal counterparty ID and EVM empty Merkle prefix, and confirms the +// registered contract exposes a valid attestation set. +func (s *Setup) AttachClient( + ctx context.Context, + router common.Address, + clientID string, + counterpartyClientID string, +) (Client, error) { + instance, err := s.AttachInstance(ctx, router) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: %w", clientID, err) + } + return s.verifyClient(ctx, instance, clientID, common.Address{}, counterpartyClientID) +} + +func (s *Setup) verifyClient( + ctx context.Context, + instance Instance, + clientID string, + expectedAddress common.Address, + counterpartyClientID string, +) (Client, error) { + if clientID == "" { + return Client{}, fmt.Errorf("solidity IBC attach Client: empty client id") + } + if counterpartyClientID == "" { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: empty counterparty client id", clientID) + } + router, err := ics26router.NewContract(instance.Router, s.backend) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: bind ICS26Router: %w", clientID, err) + } + registered, err := router.GetClient(&bind.CallOpts{Context: ctx}, clientID) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: query router client: %w", clientID, err) + } + if expectedAddress != (common.Address{}) && registered != expectedAddress { + return Client{}, fmt.Errorf( + "solidity IBC attach Client %q: router has address %s, want %s", + clientID, + registered, + expectedAddress, + ) + } + if registered == (common.Address{}) { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: router returned a zero contract address", clientID) + } + if codeErr := s.requireCode(ctx, "attestation Client "+clientID, registered); codeErr != nil { + return Client{}, codeErr + } + counterparty, err := router.GetCounterparty(&bind.CallOpts{Context: ctx}, clientID) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: query counterparty: %w", clientID, err) + } + if counterparty.ClientId != counterpartyClientID { + return Client{}, fmt.Errorf( + "solidity IBC attach Client %q: counterparty id is %q, want %q", + clientID, + counterparty.ClientId, + counterpartyClientID, + ) + } + if len(counterparty.MerklePrefix) != 1 || len(counterparty.MerklePrefix[0]) != 0 { + return Client{}, fmt.Errorf( + "solidity IBC attach Client %q: counterparty Merkle prefix is not the EVM empty prefix", + clientID, + ) + } + + lightClient, err := attestation.NewContract(registered, s.backend) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: bind attestation contract: %w", clientID, err) + } + set, err := lightClient.GetAttestationSet(&bind.CallOpts{Context: ctx}) + if err != nil { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: query attestation set: %w", clientID, err) + } + if len(set.AttestorAddresses) == 0 || set.MinRequiredSigs == 0 || + int(set.MinRequiredSigs) > len(set.AttestorAddresses) { + return Client{}, fmt.Errorf("solidity IBC attach Client %q: invalid attestation set", clientID) + } + return Client{ + ID: clientID, + Address: registered, + CounterpartyClientID: counterpartyClientID, + Attestors: slices.Clone(set.AttestorAddresses), + MinRequiredSignatures: set.MinRequiredSigs, + }, nil +} + +func (s *Setup) send( + ctx context.Context, + authority evm.Account, + fn func(*bind.TransactOpts) (common.Address, *types.Transaction, error), +) (common.Address, *types.Transaction, error) { + opts, err := authority.TransactOpts(s.chainID) + if err != nil { + return common.Address{}, nil, err + } + opts.Context = ctx + opts.NoSend = true + address, tx, err := fn(opts) + if err != nil { + return common.Address{}, nil, err + } + if tx == nil { + return common.Address{}, nil, fmt.Errorf("transaction was not constructed") + } + if err := s.backend.SendTransaction(ctx, tx); err != nil { + return address, tx, fmt.Errorf("broadcast transaction %s: %w", tx.Hash(), err) + } + return address, tx, nil +} + +func (s *Setup) awaitMined( + ctx context.Context, + stage string, + tx *types.Transaction, +) (*types.Receipt, error) { + if tx == nil { + return nil, fmt.Errorf("solidity IBC %s: transaction was not returned", stage) + } + receipt, err := bind.WaitMined(ctx, s.backend, tx) + if err != nil { + return nil, fmt.Errorf( + "solidity IBC %s: wait for transaction %s: %w", + stage, + tx.Hash(), + err, + ) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return receipt, fmt.Errorf("solidity IBC %s: transaction %s reverted", stage, tx.Hash()) + } + return receipt, nil +} + +func isIBCClientNotFound(err error) bool { + revertData, ok := ethclient.RevertErrorData(err) + if !ok || len(revertData) < 4 { + return false + } + routerABI, abiErr := ics26router.ContractMetaData.GetAbi() + if abiErr != nil || routerABI == nil { + return false + } + var selector [4]byte + copy(selector[:], revertData) + contractErr, lookupErr := routerABI.ErrorByID(selector) + return lookupErr == nil && contractErr.Name == "IBCClientNotFound" +} + +func (s *Setup) requireCode(ctx context.Context, label string, address common.Address) error { + code, err := s.backend.CodeAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("solidity IBC verify %s %s code: %w", label, address, err) + } + if len(code) == 0 { + return fmt.Errorf("solidity IBC verify %s %s: no contract code", label, address) + } + return nil +} + +func (s *Setup) requireAdmin(ctx context.Context, accessManager, authority common.Address) error { + contract, err := accessmanager.NewAccessManager(accessManager, s.backend) + if err != nil { + return fmt.Errorf("bind AccessManager: %w", err) + } + role, err := contract.HasRole(&bind.CallOpts{Context: ctx}, uint64(0), authority) + if err != nil { + return fmt.Errorf("query admin role: %w", err) + } + if !role.IsMember { + return fmt.Errorf("address %s is not an AccessManager admin", authority) + } + return nil +} + +func (s *Setup) requireAccessManager(ctx context.Context, address common.Address) error { + contract, err := accessmanager.NewAccessManager(address, s.backend) + if err != nil { + return fmt.Errorf("bind AccessManager: %w", err) + } + adminRole, err := contract.ADMINROLE(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("query ADMIN_ROLE: %w", err) + } + if adminRole != 0 { + return fmt.Errorf("ADMIN_ROLE is %d, want 0", adminRole) + } + return nil +} + +func (s *Setup) requireCanAddCustomClient( + ctx context.Context, + instance Instance, + authority common.Address, +) error { + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return fmt.Errorf("read ICS26Router ABI: %w", err) + } + if routerABI == nil { + return fmt.Errorf("upstream ICS26Router binding has no ABI") + } + selector, err := customAddClientSelector(*routerABI) + if err != nil { + return err + } + + contract, err := accessmanager.NewAccessManager(instance.AccessManager, s.backend) + if err != nil { + return fmt.Errorf("bind AccessManager: %w", err) + } + permission, err := contract.CanCall( + &bind.CallOpts{Context: ctx}, + authority, + instance.Router, + selector, + ) + if err != nil { + return fmt.Errorf("query addClient permission: %w", err) + } + if !permission.Immediate { + return fmt.Errorf("address %s cannot call custom addClient immediately (delay %d)", authority, permission.Delay) + } + return nil +} + +func customAddClientSelector(routerABI abi.ABI) ([4]byte, error) { + for _, method := range routerABI.Methods { + if method.RawName == "addClient" && len(method.Inputs) == 3 { + var selector [4]byte + copy(selector[:], method.ID) + return selector, nil + } + } + return [4]byte{}, fmt.Errorf("upstream ICS26Router ABI has no custom addClient overload") +} + +func validateAuthority(authority evm.Account) error { + if authority.Address() == (common.Address{}) { + return fmt.Errorf("authority is required") + } + return nil +} + +func requireDeploymentAddress(label string, predicted common.Address, receipt *types.Receipt) error { + if receipt == nil { + return fmt.Errorf("solidity IBC deploy %s: no mined receipt", label) + } + if receipt.ContractAddress != predicted { + return fmt.Errorf( + "solidity IBC deploy %s: receipt contract address is %s, predicted %s", + label, + receipt.ContractAddress, + predicted, + ) + } + return nil +} diff --git a/e2e/internal/harness/environment/solidityibc/setup_test.go b/e2e/internal/harness/environment/solidityibc/setup_test.go new file mode 100644 index 000000000..aec17efb9 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/setup_test.go @@ -0,0 +1,230 @@ +package solidityibc + +import ( + "context" + "errors" + "math/big" + "sync" + "testing" + "time" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/environment/solidityibc/accessmanager" + + gethtypes "github.com/ethereum/go-ethereum/core/types" +) + +type failSendBackend struct { + contractBackend + failAt int + sends int + hash common.Hash +} + +func (b *failSendBackend) SendTransaction(ctx context.Context, tx *gethtypes.Transaction) error { + b.sends++ + if b.sends == b.failAt { + b.hash = tx.Hash() + return errors.New("injected transaction submission failure") + } + return b.contractBackend.SendTransaction(ctx, tx) +} + +func TestSetupDeploysAndAttachesSolidityIBCInstanceAndClient(t *testing.T) { + authority, err := evm.NewAccount() + require.NoError(t, err) + attestor, err := evm.NewAccount() + require.NoError(t, err) + secondAttestor, err := evm.NewAccount() + require.NoError(t, err) + clientAuthority, err := evm.NewAccount() + require.NoError(t, err) + + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + authority.Address(): {Balance: ether(100)}, + clientAuthority.Address(): {Balance: ether(100)}, + }) + startMining(t, backend) + setup, err := newSetup(backend.Client(), big.NewInt(1337)) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + instance, err := setup.DeployInstance(ctx, authority) + require.NoError(t, err) + + attachedInstance, err := setup.AttachInstance(ctx, instance.Router) + require.NoError(t, err) + require.Equal(t, instance, attachedInstance) + grantCustomClientRole(ctx, t, setup, authority, instance, clientAuthority.Address()) + + prepared, err := setup.PrepareClient(ctx, clientAuthority, instance.Router, AttestationClientConfig{ + ID: "eth-chain-b", + CounterpartyClientID: "eth-chain-a", + Attestors: []common.Address{attestor.Address(), secondAttestor.Address()}, + MinRequiredSignatures: 2, + InitialHeight: 1, + InitialTimestamp: 1_700_000_000, + }) + require.NoError(t, err) + client, err := prepared.Deploy(ctx) + require.NoError(t, err) + require.Equal(t, "eth-chain-b", client.ID) + require.Equal(t, "eth-chain-a", client.CounterpartyClientID) + require.Equal(t, []common.Address{attestor.Address(), secondAttestor.Address()}, client.Attestors) + require.Equal(t, uint8(2), client.MinRequiredSignatures) + + attachedClient, err := setup.AttachClient(ctx, instance.Router, client.ID, client.CounterpartyClientID) + require.NoError(t, err) + require.Equal(t, client, attachedClient) + + _, err = setup.AttachClient(ctx, instance.Router, client.ID, "wrong-counterparty") + require.ErrorContains(t, err, `counterparty id is "eth-chain-a", want "wrong-counterparty"`) + + // Duplicate registration is rejected by a read-only vacancy check before a + // second light-client contract is deployed. + _, err = setup.PrepareClient(ctx, clientAuthority, instance.Router, AttestationClientConfig{ + ID: "eth-chain-b", + CounterpartyClientID: "eth-chain-a", + Attestors: []common.Address{attestor.Address(), secondAttestor.Address()}, + MinRequiredSignatures: 2, + InitialHeight: 2, + InitialTimestamp: 1_700_000_001, + }) + require.ErrorContains(t, err, `Client "eth-chain-b" is already registered`) +} + +func TestPrepareClientRejectsInvalidConfigurationBeforeSideEffects(t *testing.T) { + authority, err := evm.NewAccount() + require.NoError(t, err) + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + authority.Address(): {Balance: ether(1)}, + }) + t.Cleanup(func() { require.NoError(t, backend.Close()) }) + setup, err := newSetup(backend.Client(), big.NewInt(1337)) + require.NoError(t, err) + + prepared, err := setup.PrepareClient(context.Background(), authority, common.Address{}, AttestationClientConfig{ + ID: "client-0", + CounterpartyClientID: "remote", + Attestors: []common.Address{authority.Address()}, + MinRequiredSignatures: 1, + InitialHeight: 1, + InitialTimestamp: 1, + }) + require.ErrorContains(t, err, "not a valid Solidity IBC custom client identifier") + require.Nil(t, prepared) +} + +func TestDeployInstanceReportsBroadcastFailure(t *testing.T) { + authority, err := evm.NewAccount() + require.NoError(t, err) + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + authority.Address(): {Balance: ether(100)}, + }) + startMining(t, backend) + failing := &failSendBackend{contractBackend: backend.Client(), failAt: 2} + setup, err := newSetup(failing, big.NewInt(1337)) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + instance, err := setup.DeployInstance(ctx, authority) + require.Equal(t, Instance{}, instance) + require.ErrorContains(t, err, "deploy ICS26Router implementation") + require.ErrorContains(t, err, "injected transaction submission failure") + require.ErrorContains(t, err, failing.hash.Hex()) +} + +func TestAwaitMinedReturnsCancellation(t *testing.T) { + authority, err := evm.NewAccount() + require.NoError(t, err) + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + authority.Address(): {Balance: ether(1)}, + }) + t.Cleanup(func() { require.NoError(t, backend.Close()) }) + setup, err := newSetup(backend.Client(), big.NewInt(1337)) + require.NoError(t, err) + tx := gethtypes.NewTx(&gethtypes.LegacyTx{Nonce: 77, Gas: 21_000}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + receipt, err := setup.awaitMined(ctx, "canceled confirmation", tx) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, receipt) +} + +func startMining(t *testing.T, backend *simulated.Backend) { + t.Helper() + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + backend.Commit() + } + } + }() + t.Cleanup(func() { + close(stop) + wg.Wait() + require.NoError(t, backend.Close()) + }) +} + +func grantCustomClientRole( + ctx context.Context, + t *testing.T, + setup *Setup, + admin evm.Account, + instance Instance, + account common.Address, +) { + t.Helper() + routerABI, err := ics26router.ContractMetaData.GetAbi() + require.NoError(t, err) + require.NotNil(t, routerABI) + selector, err := customAddClientSelector(*routerABI) + require.NoError(t, err) + manager, err := accessmanager.NewAccessManager(instance.AccessManager, setup.backend) + require.NoError(t, err) + + const roleID uint64 = 42 + _, tx, err := setup.send(ctx, admin, func(opts *bind.TransactOpts) (common.Address, *gethtypes.Transaction, error) { + transaction, sendErr := manager.SetTargetFunctionRole( + opts, + instance.Router, + [][4]byte{selector}, + roleID, + ) + return common.Address{}, transaction, sendErr + }) + require.NoError(t, err) + _, err = setup.awaitMined(ctx, "configure custom Client role", tx) + require.NoError(t, err) + + _, tx, err = setup.send(ctx, admin, func(opts *bind.TransactOpts) (common.Address, *gethtypes.Transaction, error) { + transaction, sendErr := manager.GrantRole(opts, roleID, account, uint32(0)) + return common.Address{}, transaction, sendErr + }) + require.NoError(t, err) + _, err = setup.awaitMined(ctx, "grant custom Client role", tx) + require.NoError(t, err) +} + +func ether(amount int64) *big.Int { + return new(big.Int).Mul(big.NewInt(amount), big.NewInt(1_000_000_000_000_000_000)) +} diff --git a/e2e/internal/harness/environment/solidityibc/types.go b/e2e/internal/harness/environment/solidityibc/types.go new file mode 100644 index 000000000..e02dc3895 --- /dev/null +++ b/e2e/internal/harness/environment/solidityibc/types.go @@ -0,0 +1,103 @@ +// Package solidityibc realizes the concrete Solidity IBC EVM protocol state. +package solidityibc + +import ( + "fmt" + "slices" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// Instance is one initialized ICS26 router installation. The router address is +// the ERC1967 proxy used by clients and relayers; AccessManager controls its +// restricted operations. +type Instance struct { + AccessManager common.Address + Router common.Address +} + +// Client is an attestation light client registered with one Instance. +type Client struct { + ID string + Address common.Address + CounterpartyClientID string + Attestors []common.Address + MinRequiredSignatures uint8 +} + +// AttestationClientConfig contains the immutable constructor inputs for an +// attestation light client. A zero RoleManager intentionally permits anyone to +// submit proofs, matching Solidity IBC's constructor semantics. +type AttestationClientConfig struct { + ID string + CounterpartyClientID string + Attestors []common.Address + MinRequiredSignatures uint8 + InitialHeight uint64 + InitialTimestamp uint64 + RoleManager common.Address +} + +func (c AttestationClientConfig) snapshot() AttestationClientConfig { + c.Attestors = slices.Clone(c.Attestors) + return c +} + +func (c AttestationClientConfig) validate() error { + if !validCustomClientID(c.ID) { + return fmt.Errorf("client id %q is not a valid Solidity IBC custom client identifier", c.ID) + } + if c.CounterpartyClientID == "" { + return fmt.Errorf("client %q has an empty counterparty client id", c.ID) + } + if len(c.Attestors) == 0 { + return fmt.Errorf("client %q has no attestors", c.ID) + } + seen := make(map[common.Address]struct{}, len(c.Attestors)) + for _, address := range c.Attestors { + if address == (common.Address{}) { + return fmt.Errorf("client %q has a zero attestor address", c.ID) + } + if _, duplicate := seen[address]; duplicate { + return fmt.Errorf("client %q repeats attestor %s", c.ID, address) + } + seen[address] = struct{}{} + } + if c.MinRequiredSignatures == 0 || int(c.MinRequiredSignatures) > len(c.Attestors) { + return fmt.Errorf( + "client %q requires %d signatures from %d attestors", + c.ID, + c.MinRequiredSignatures, + len(c.Attestors), + ) + } + if c.InitialHeight == 0 { + return fmt.Errorf("client %q has zero initial height", c.ID) + } + if c.InitialTimestamp == 0 { + return fmt.Errorf("client %q has zero initial timestamp", c.ID) + } + return nil +} + +// validCustomClientID mirrors Solidity IBC v3's IBCIdentifiers validation. The +// access-controlled addClient overload rejects generated "client-" identifiers +// and accepts only these explicit custom identifiers. +func validCustomClientID(id string) bool { + if len(id) < 4 || len(id) > 128 || strings.HasPrefix(id, "channel-") || strings.HasPrefix(id, "client-") { + return false + } + for _, b := range []byte(id) { + if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' { + continue + } + switch b { + case '.', '_', '+', '-', '#', '[', ']', '<', '>': + continue + default: + return false + } + } + return true +} diff --git a/e2e/internal/harness/environment/spec.go b/e2e/internal/harness/environment/spec.go new file mode 100644 index 000000000..4d23584a6 --- /dev/null +++ b/e2e/internal/harness/environment/spec.go @@ -0,0 +1,629 @@ +// Package environment describes and realizes IBC test environments. +package environment + +import ( + "fmt" + "slices" + "time" +) + +// Graph identities are distinct types so references to different resource +// families cannot be accidentally interchanged. +type ( + ChainID string + IBCInstanceID string + ConnectionID string + ClientID string + AttestorID string + RelayerID string + + // EndpointBindingID and AuthorityID name values supplied separately at runtime. + EndpointBindingID string + AuthorityID string + + // Existing resource locators are authored identifiers whose concrete + // interpretation belongs to realization, not to the desired graph. + IBCInstanceLocator string + IBCClientLocator string +) + +// Spec is the desired IBC resource graph. Typed references determine dependency +// order; protocol declarations retain authored order while Chains start concurrently. +type Spec struct { + Chains []ChainSpec + IBCInstances []IBCInstanceSpec + Connections []ConnectionSpec + Attestors []AttestorSpec + Relayers []RelayerSpec +} + +func (s Spec) snapshot() Spec { + out := Spec{ + Chains: slices.Clone(s.Chains), + IBCInstances: slices.Clone(s.IBCInstances), + Connections: slices.Clone(s.Connections), + Attestors: slices.Clone(s.Attestors), + Relayers: slices.Clone(s.Relayers), + } + // slices.Clone copies the RelayerSpec structs but leaves each nested + // Connections slice sharing the caller's backing array; deep-copy it so a + // mutation of the original spec after snapshot cannot reach the environment's + // retained copy. + for i := range out.Relayers { + out.Relayers[i].Connections = slices.Clone(out.Relayers[i].Connections) + } + return out +} + +// ChainSpec is sealed because acquisition and ownership semantics differ by +// concrete Chain declaration. +type ChainSpec interface { + chainSpec() + chainID() ChainID + validateChain() error +} + +// ManagedAnvil declares an environment-owned Anvil Chain. A zero +// BlockInterval selects the launcher's instant-mining behavior; positive +// intervals must use the launcher's whole-second granularity. +type ManagedAnvil struct { + ID ChainID + EVMChainID uint64 + BlockInterval time.Duration +} + +func (ManagedAnvil) chainSpec() {} +func (c ManagedAnvil) chainID() ChainID { + return c.ID +} + +func (c ManagedAnvil) validateChain() error { + if err := requireValue("chain id", string(c.ID)); err != nil { + return err + } + if c.EVMChainID == 0 { + return errorsf("chain %q: EVM chain id must be greater than zero", c.ID) + } + if c.BlockInterval < 0 { + return errorsf("chain %q: block interval must not be negative", c.ID) + } + if c.BlockInterval > 0 && c.BlockInterval%time.Second != 0 { + return errorsf("chain %q: block interval must be a whole number of seconds", c.ID) + } + return nil +} + +// ManagedBesu declares an environment-owned Besu Chain. +type ManagedBesu struct { + ID ChainID + EVMChainID uint64 +} + +func (ManagedBesu) chainSpec() {} +func (c ManagedBesu) chainID() ChainID { + return c.ID +} + +func (c ManagedBesu) validateChain() error { + if err := requireValue("chain id", string(c.ID)); err != nil { + return err + } + if c.EVMChainID == 0 { + return errorsf("chain %q: EVM chain id must be greater than zero", c.ID) + } + return nil +} + +// AttachedEVM declares a borrowed EVM Chain. Endpoint names a runtime binding +// rather than embedding an RPC URL in the durable Spec. Timing is mandatory +// because an attached Chain cannot safely inherit a local launcher default. +type AttachedEVM struct { + ID ChainID + EVMChainID uint64 + Endpoint EndpointBindingID + Timing Timing +} + +func (AttachedEVM) chainSpec() {} +func (c AttachedEVM) chainID() ChainID { + return c.ID +} + +func (c AttachedEVM) validateChain() error { + if err := requireValue("chain id", string(c.ID)); err != nil { + return err + } + if c.EVMChainID == 0 { + return errorsf("chain %q: EVM chain id must be greater than zero", c.ID) + } + if err := requireValue(fmt.Sprintf("chain %q endpoint binding", c.ID), string(c.Endpoint)); err != nil { + return err + } + return c.Timing.validate(c.ID) +} + +// Timing describes the behavior workflows need in order to wait for an +// attached Chain without assuming Anvil or Besu defaults. BlockInterval may be +// zero for instant mining; all wait budgets must be positive. +type Timing struct { + BlockInterval time.Duration + CompletionBudget time.Duration + SettleWindow time.Duration + PollInterval time.Duration +} + +func (t Timing) validate(id ChainID) error { + if t.BlockInterval < 0 { + return errorsf("chain %q timing: block interval must not be negative", id) + } + if t.CompletionBudget <= 0 { + return errorsf("chain %q timing: completion budget must be greater than zero", id) + } + if t.SettleWindow <= 0 { + return errorsf("chain %q timing: settle window must be greater than zero", id) + } + if t.PollInterval <= 0 { + return errorsf("chain %q timing: poll interval must be greater than zero", id) + } + return nil +} + +// IBCInstanceSpec is sealed because creating protocol state and using +// explicitly identified existing state have different semantics. +type IBCInstanceSpec interface { + ibcInstanceSpec() + ibcInstanceID() IBCInstanceID + ibcInstanceChain() ChainID + validateIBCInstance() error +} + +// NewIBCInstance declares a new IBC installation on Chain. Its lifetime is +// bounded by a managed host or durable on an attached host. Authority names +// the runtime-provided signer permitted to create it. +type NewIBCInstance struct { + ID IBCInstanceID + Chain ChainID + Authority AuthorityID +} + +func (NewIBCInstance) ibcInstanceSpec() {} +func (i NewIBCInstance) ibcInstanceID() IBCInstanceID { + return i.ID +} + +func (i NewIBCInstance) ibcInstanceChain() ChainID { + return i.Chain +} + +func (i NewIBCInstance) validateIBCInstance() error { + if err := requireValue("IBC Instance id", string(i.ID)); err != nil { + return err + } + if err := requireValue(fmt.Sprintf("IBC Instance %q chain", i.ID), string(i.Chain)); err != nil { + return err + } + return requireValue(fmt.Sprintf("IBC Instance %q authority", i.ID), string(i.Authority)) +} + +// ExistingIBCInstance declares explicitly identified, borrowed protocol state. +type ExistingIBCInstance struct { + ID IBCInstanceID + Chain ChainID + Locator IBCInstanceLocator +} + +func (ExistingIBCInstance) ibcInstanceSpec() {} +func (i ExistingIBCInstance) ibcInstanceID() IBCInstanceID { + return i.ID +} + +func (i ExistingIBCInstance) ibcInstanceChain() ChainID { + return i.Chain +} + +func (i ExistingIBCInstance) validateIBCInstance() error { + if err := requireValue("IBC Instance id", string(i.ID)); err != nil { + return err + } + if err := requireValue(fmt.Sprintf("IBC Instance %q chain", i.ID), string(i.Chain)); err != nil { + return err + } + return requireValue(fmt.Sprintf("IBC Instance %q locator", i.ID), string(i.Locator)) +} + +// clientIdentityValue is the common internal identity carried by both Client +// declaration variants. +type clientIdentityValue struct { + ID ClientID + IBCInstance IBCInstanceID +} + +// ClientSpec is sealed because creating an IBC Client and using explicitly +// identified existing state have different durability and mutation semantics. +// A Connection may combine either variant at each end. +type ClientSpec interface { + clientSpec() +} + +// NewClient declares an attestation IBC Client to create on its host IBC +// Instance. When IBCInstance refers to a NewIBCInstance, Authority must resolve +// to the same address as that Instance's Authority. The quorum is explicit +// because no single default is safe for every attestor set. +type NewClient struct { + ID ClientID + IBCInstance IBCInstanceID + Authority AuthorityID + MinRequiredSignatures uint8 +} + +func (NewClient) clientSpec() {} + +// ExistingClient identifies an already-created IBC Client. +type ExistingClient struct { + ID ClientID + IBCInstance IBCInstanceID + Locator IBCClientLocator +} + +func (ExistingClient) clientSpec() {} + +// ConnectionSpec is a reciprocal pair of IBC Clients. Each end independently +// declares whether the authored Client identity will be created or resolved +// from an existing protocol locator. +type ConnectionSpec struct { + ID ConnectionID + A ClientSpec + B ClientSpec +} + +func (c ConnectionSpec) validate() error { + if err := requireValue("IBC Connection id", string(c.ID)); err != nil { + return err + } + a, err := validateClientSpec(c.ID, "A", c.A) + if err != nil { + return err + } + b, err := validateClientSpec(c.ID, "B", c.B) + if err != nil { + return err + } + if a.ID == b.ID { + return errorsf("IBC Connection %q: clients must have distinct ids", c.ID) + } + if a.IBCInstance == b.IBCInstance { + return errorsf("IBC Connection %q: clients must belong to distinct IBC Instances", c.ID) + } + return nil +} + +func validateClientSpec(connectionID ConnectionID, end string, spec ClientSpec) (clientIdentityValue, error) { + var ( + client clientIdentityValue + variantField string + variantValue string + ) + switch declaration := spec.(type) { + case NewClient: + client = clientIdentityValue{ID: declaration.ID, IBCInstance: declaration.IBCInstance} + variantField = "authority" + variantValue = string(declaration.Authority) + if declaration.MinRequiredSignatures == 0 { + return clientIdentityValue{}, errorsf( + "IBC Connection %q client %q minimum required signatures must be greater than zero", + connectionID, + client.ID, + ) + } + case ExistingClient: + client = clientIdentityValue{ID: declaration.ID, IBCInstance: declaration.IBCInstance} + variantField = "locator" + variantValue = string(declaration.Locator) + default: + return clientIdentityValue{}, errorsf( + "IBC Connection %q end %s: unsupported declaration %T; use a concrete value", + connectionID, + end, + spec, + ) + } + + if err := requireValue(fmt.Sprintf("IBC Connection %q client id", connectionID), string(client.ID)); err != nil { + return clientIdentityValue{}, err + } + if err := requireValue( + fmt.Sprintf("IBC Connection %q client %q IBC Instance", connectionID, client.ID), + string(client.IBCInstance), + ); err != nil { + return clientIdentityValue{}, err + } + if err := requireValue( + fmt.Sprintf("IBC Connection %q client %q %s", connectionID, client.ID, variantField), + variantValue, + ); err != nil { + return clientIdentityValue{}, err + } + return client, nil +} + +func clientIdentity(spec ClientSpec) clientIdentityValue { + switch declaration := spec.(type) { + case NewClient: + return clientIdentityValue{ID: declaration.ID, IBCInstance: declaration.IBCInstance} + case ExistingClient: + return clientIdentityValue{ID: declaration.ID, IBCInstance: declaration.IBCInstance} + default: + panic(fmt.Sprintf("environment: unsupported validated IBC Client declaration %T", spec)) + } +} + +// AttestorSpec declares an Attestor scoped to one authored IBC Client. It signs +// that Client's attestations using a stable runtime authority binding. Its +// observed counterparty IBC Instance is derived from the Client's Connection. +type AttestorSpec struct { + ID AttestorID + Client ClientID + Authority AuthorityID + // SigningKeyAuthority is a fault-injection override for process (standalone) + // Attestors only. When set, it makes the Attestor PROCESS sign with this + // authority's key instead of Authority's. The on-chain registration and the + // Relayer's configured attestor evmAddress still derive from Authority, so a + // non-empty override deliberately produces a registered-vs-signing identity + // mismatch that exercises the Relayer's attestor identity enforcement. Empty + // means the process signs with Authority's key. It is ineffective for an + // Attestor folded into an EmbedAttestors Relayer (which signs in-process with + // its registered Authority), so validation rejects that combination. + SigningKeyAuthority AuthorityID +} + +// RelayerSpec declares a truthful protocol Relayer that runs `ibc relayer run`, +// serving both directions of every IBC Connection it lists. +// +// Authority names the funded EVM account whose key signs relay transactions on +// every Chain of the served Connections. AutoRelay selects whether routes relay +// automatically (nil or true) or wait for an explicit manual relay request +// (false). Lookback is the optional auto-relay lookback; realization defaults +// it when zero. +// +// EmbedAttestors selects dual mode: the Attestors serving this Relayer's +// Connections are folded into this one `ibc relayer run` process (relayer + +// attestor + signer from one config) instead of being spawned as separate +// attestor processes. The Attestor declarations are still required so their +// signer keys register on the light clients; realization simply routes the +// relayer to them in-process rather than over gRPC. +type RelayerSpec struct { + ID RelayerID + // Connections names the IBC Connections this one Relayer process serves; at + // least one is required. Every served Connection must share the same Chain set + // (and the same ICS26 router per Chain), so one `ibc relayer run` config can + // list each Chain once. Two Connections with independent Client pairs and + // attestor sets let a single Relayer prove cross-route isolation: a fault on one + // route's attestors must not stall the other. + Connections []ConnectionID + Authority AuthorityID + AutoRelay *bool + Lookback uint64 + EmbedAttestors bool + // PostgresURL overrides the Relayer's persistence backend. Empty selects the + // default embedded sqlite database in the Relayer's workspace; a non-empty DSN + // runs the Relayer against that external Postgres. + PostgresURL string +} + +// validate checks the authored graph without acquiring resources or resolving +// runtime endpoint and authority bindings. +func (s Spec) validate() error { + chains := make(map[ChainID]struct{}, len(s.Chains)) + attachedChains := make(map[ChainID]struct{}, len(s.Chains)) + evmChainIDs := make(map[uint64]ChainID, len(s.Chains)) + for n, chain := range s.Chains { + switch chain.(type) { + case ManagedAnvil, ManagedBesu, AttachedEVM: + default: + return errorsf("chains[%d]: unsupported declaration %T; use a concrete value", n, chain) + } + if err := chain.validateChain(); err != nil { + return err + } + id := chain.chainID() + if _, exists := chains[id]; exists { + return errorsf("duplicate Chain id %q", id) + } + evmID := chainEVMID(chain) + if previous, exists := evmChainIDs[evmID]; exists { + return errorsf("Chains %q and %q use duplicate EVM chain id %d", previous, id, evmID) + } + chains[id] = struct{}{} + if _, attached := chain.(AttachedEVM); attached { + attachedChains[id] = struct{}{} + } + evmChainIDs[evmID] = id + } + + instances := make(map[IBCInstanceID]struct{}, len(s.IBCInstances)) + newInstances := make(map[IBCInstanceID]struct{}, len(s.IBCInstances)) + for n, instance := range s.IBCInstances { + switch instance.(type) { + case NewIBCInstance, ExistingIBCInstance: + default: + return errorsf("IBCInstances[%d]: unsupported declaration %T; use a concrete value", n, instance) + } + if err := instance.validateIBCInstance(); err != nil { + return err + } + id := instance.ibcInstanceID() + if _, exists := instances[id]; exists { + return errorsf("duplicate IBC Instance id %q", id) + } + if chain := instance.ibcInstanceChain(); !contains(chains, chain) { + return errorsf("IBC Instance %q references unknown Chain %q", id, chain) + } + if existing, ok := instance.(ExistingIBCInstance); ok && !contains(attachedChains, existing.Chain) { + return errorsf( + "existing IBC Instance %q must belong to an attached Chain, but %q is managed", + existing.ID, + existing.Chain, + ) + } + instances[id] = struct{}{} + if _, isNew := instance.(NewIBCInstance); isNew { + newInstances[id] = struct{}{} + } + } + + connections := make(map[ConnectionID]struct{}, len(s.Connections)) + clients := make(map[ClientID]ConnectionID, 2*len(s.Connections)) + for _, connection := range s.Connections { + if err := connection.validate(); err != nil { + return err + } + if _, exists := connections[connection.ID]; exists { + return errorsf("duplicate IBC Connection id %q", connection.ID) + } + for _, declaration := range []ClientSpec{connection.A, connection.B} { + client := clientIdentity(declaration) + if !contains(instances, client.IBCInstance) { + return errorsf( + "IBC Connection %q client %q references unknown IBC Instance %q", + connection.ID, + client.ID, + client.IBCInstance, + ) + } + if _, existing := declaration.(ExistingClient); existing && contains(newInstances, client.IBCInstance) { + return errorsf( + "IBC Connection %q existing client %q cannot belong to new IBC Instance %q", + connection.ID, + client.ID, + client.IBCInstance, + ) + } + if owner, exists := clients[client.ID]; exists { + return errorsf("duplicate IBC Client id %q in Connections %q and %q", client.ID, owner, connection.ID) + } + clients[client.ID] = connection.ID + } + connections[connection.ID] = struct{}{} + } + + attestors := make(map[AttestorID]struct{}, len(s.Attestors)) + attestorsByClient := make(map[ClientID]int, len(s.Attestors)) + for _, attestor := range s.Attestors { + if err := requireValue("Attestor id", string(attestor.ID)); err != nil { + return err + } + if _, exists := attestors[attestor.ID]; exists { + return errorsf("duplicate Attestor id %q", attestor.ID) + } + if err := requireValue(fmt.Sprintf("Attestor %q client", attestor.ID), string(attestor.Client)); err != nil { + return err + } + if _, exists := clients[attestor.Client]; !exists { + return errorsf("Attestor %q references unknown IBC Client %q", attestor.ID, attestor.Client) + } + if err := requireValue( + fmt.Sprintf("Attestor %q authority", attestor.ID), + string(attestor.Authority), + ); err != nil { + return err + } + attestors[attestor.ID] = struct{}{} + attestorsByClient[attestor.Client]++ + } + for _, connection := range s.Connections { + for _, declaration := range []ClientSpec{connection.A, connection.B} { + client, isNew := declaration.(NewClient) + if isNew && attestorsByClient[client.ID] == 0 { + return errorsf("IBC Connection %q client %q must have at least one Attestor", connection.ID, client.ID) + } + if isNew && int(client.MinRequiredSignatures) > attestorsByClient[client.ID] { + return errorsf( + "IBC Connection %q client %q requires %d signatures from %d Attestors", + connection.ID, + client.ID, + client.MinRequiredSignatures, + attestorsByClient[client.ID], + ) + } + } + } + + relayers := make(map[RelayerID]struct{}, len(s.Relayers)) + for _, relayer := range s.Relayers { + if err := requireValue("Relayer id", string(relayer.ID)); err != nil { + return err + } + if _, exists := relayers[relayer.ID]; exists { + return errorsf("duplicate Relayer id %q", relayer.ID) + } + if len(relayer.Connections) == 0 { + return errorsf("Relayer %q must serve at least one IBC Connection", relayer.ID) + } + seenConnections := make(map[ConnectionID]struct{}, len(relayer.Connections)) + for _, id := range relayer.Connections { + if _, exists := connections[id]; !exists { + return errorsf("Relayer %q references unknown IBC Connection %q", relayer.ID, id) + } + if _, dup := seenConnections[id]; dup { + return errorsf("Relayer %q references IBC Connection %q more than once", relayer.ID, id) + } + seenConnections[id] = struct{}{} + } + if err := requireValue( + fmt.Sprintf("Relayer %q authority", relayer.ID), + string(relayer.Authority), + ); err != nil { + return err + } + relayers[relayer.ID] = struct{}{} + } + + // A SigningKeyAuthority override only takes effect on a standalone `ibc attestor + // run` process; an Attestor folded into an EmbedAttestors Relayer signs in-process + // with its registered Authority and silently ignores the override. Reject that + // combination so the fault injection cannot be authored where it does nothing. + folded := foldedOnlyAttestorIDs(s) + for _, attestor := range s.Attestors { + if attestor.SigningKeyAuthority == "" { + continue + } + if _, embedded := folded[attestor.ID]; embedded { + return errorsf( + "Attestor %q sets SigningKeyAuthority but is folded into an EmbedAttestors Relayer; "+ + "the signing-key override is a fault injection for process (standalone) attestors only", + attestor.ID, + ) + } + } + + return nil +} + +func chainEVMID(spec ChainSpec) uint64 { + switch chain := spec.(type) { + case ManagedAnvil: + return chain.EVMChainID + case ManagedBesu: + return chain.EVMChainID + case AttachedEVM: + return chain.EVMChainID + default: + panic(fmt.Sprintf("environment: unsupported validated Chain declaration %T", spec)) + } +} + +func contains[ID comparable](set map[ID]struct{}, id ID) bool { + _, ok := set[id] + return ok +} + +func requireValue(field, value string) error { + if value == "" { + return errorsf("%s is required", field) + } + return nil +} + +func errorsf(format string, args ...any) error { + return fmt.Errorf("environment Spec: "+format, args...) +} diff --git a/e2e/internal/harness/environment/spec_test.go b/e2e/internal/harness/environment/spec_test.go new file mode 100644 index 000000000..4050f9468 --- /dev/null +++ b/e2e/internal/harness/environment/spec_test.go @@ -0,0 +1,497 @@ +package environment + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSpecValidateMixedGraph(t *testing.T) { + spec := validSpec() + require.NoError(t, spec.validate()) + + // Multiple independently identified Attestors may serve the same Client. + spec.Attestors = append(spec.Attestors, AttestorSpec{ + ID: "attestor-3", Client: "client-a", Authority: "attest-a-2", + }) + require.NoError(t, spec.validate()) +} + +func TestSpecValidateStrictVariants(t *testing.T) { + tests := []struct { + name string + mutate func(*Spec) + want string + }{ + { + name: "attached endpoint binding is required", + mutate: func(s *Spec) { + chain := s.Chains[1].(AttachedEVM) + chain.Endpoint = "" + s.Chains[1] = chain + }, + want: "endpoint binding is required", + }, + { + name: "attached timing is explicit", + mutate: func(s *Spec) { + chain := s.Chains[1].(AttachedEVM) + chain.Timing = Timing{} + s.Chains[1] = chain + }, + want: "completion budget must be greater than zero", + }, + { + name: "new IBC Instance authority is required", + mutate: func(s *Spec) { + instance := s.IBCInstances[0].(NewIBCInstance) + instance.Authority = "" + s.IBCInstances[0] = instance + }, + want: "IBC Instance \"ibc-a\" authority is required", + }, + { + name: "existing IBC Instance locator is required", + mutate: func(s *Spec) { + instance := s.IBCInstances[1].(ExistingIBCInstance) + instance.Locator = "" + s.IBCInstances[1] = instance + }, + want: "IBC Instance \"ibc-b\" locator is required", + }, + { + name: "new IBC Client authority is required", + mutate: func(s *Spec) { + connection := s.Connections[0] + client := connection.A.(NewClient) + client.Authority = "" + connection.A = client + s.Connections[0] = connection + }, + want: "client \"client-a\" authority is required", + }, + { + name: "new IBC Client quorum is required", + mutate: func(s *Spec) { + connection := s.Connections[0] + client := connection.A.(NewClient) + client.MinRequiredSignatures = 0 + connection.A = client + s.Connections[0] = connection + }, + want: "minimum required signatures must be greater than zero", + }, + { + name: "existing IBC Client locator is required", + mutate: func(s *Spec) { + connection := existingConnectionSpec() + client := connection.B.(ExistingClient) + client.Locator = "" + connection.B = client + s.Connections[0] = connection + }, + want: "client \"client-b\" locator is required", + }, + { + name: "Attestor authority is required", + mutate: func(s *Spec) { + s.Attestors[0].Authority = "" + }, + want: "Attestor \"attestor-1\" authority is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec := validSpec() + tc.mutate(&spec) + require.ErrorContains(t, spec.validate(), tc.want) + }) + } +} + +func TestSpecValidateNewClientRequiresAttestor(t *testing.T) { + spec := validSpec() + spec.Attestors = spec.Attestors[:1] + require.ErrorContains(t, spec.validate(), "client \"client-b\" must have at least one Attestor") +} + +func TestSpecValidateNewClientQuorumDoesNotExceedAttestors(t *testing.T) { + spec := validSpec() + connection := spec.Connections[0] + client := connection.A.(NewClient) + client.MinRequiredSignatures = 2 + connection.A = client + spec.Connections[0] = connection + require.ErrorContains(t, spec.validate(), `client "client-a" requires 2 signatures from 1 Attestors`) +} + +func TestSpecValidateConnectionEndCombinations(t *testing.T) { + t.Run("both existing with Attestor reference", func(t *testing.T) { + spec := validSpec() + makeChainAAttached(&spec) + spec.IBCInstances[0] = ExistingIBCInstance{ID: "ibc-a", Chain: "chain-a", Locator: "0xibc-a"} + spec.Connections[0] = existingConnectionSpec() + require.NoError(t, spec.validate()) + }) + + t.Run("new and existing", func(t *testing.T) { + spec := validSpec() + makeChainAAttached(&spec) + spec.IBCInstances[0] = ExistingIBCInstance{ID: "ibc-a", Chain: "chain-a", Locator: "0xibc-a"} + connection := spec.Connections[0] + connection.A = existingConnectionSpec().A + spec.Connections[0] = connection + require.NoError(t, spec.validate()) + }) + + t.Run("existing Instance requires attached Chain", func(t *testing.T) { + spec := validSpec() + spec.IBCInstances[0] = ExistingIBCInstance{ + ID: "ibc-a", Chain: "chain-a", Locator: "0xibc-a", + } + require.ErrorContains( + t, + spec.validate(), + `existing IBC Instance "ibc-a" must belong to an attached Chain, but "chain-a" is managed`, + ) + }) + + t.Run("existing Client cannot belong to new Instance", func(t *testing.T) { + spec := validSpec() + connection := spec.Connections[0] + connection.A = existingConnectionSpec().A + spec.Connections[0] = connection + require.ErrorContains( + t, + spec.validate(), + `existing client "client-a" cannot belong to new IBC Instance "ibc-a"`, + ) + }) +} + +func TestSpecValidateIdentityAndReferences(t *testing.T) { + tests := []struct { + name string + mutate func(*Spec) + want string + }{ + { + name: "duplicate Chain id", + mutate: func(s *Spec) { + s.Chains = append(s.Chains, ManagedAnvil{ID: "chain-a", EVMChainID: 31339}) + }, + want: "duplicate Chain id \"chain-a\"", + }, + { + name: "duplicate EVM chain id", + mutate: func(s *Spec) { + s.Chains = append(s.Chains, ManagedAnvil{ID: "chain-c", EVMChainID: 31337}) + }, + want: `Chains "chain-a" and "chain-c" use duplicate EVM chain id 31337`, + }, + { + name: "duplicate IBC Instance id", + mutate: func(s *Spec) { + s.IBCInstances = append(s.IBCInstances, NewIBCInstance{ + ID: "ibc-a", Chain: "chain-a", Authority: "deploy-a", + }) + }, + want: "duplicate IBC Instance id \"ibc-a\"", + }, + { + name: "IBC Instance references unknown Chain", + mutate: func(s *Spec) { + instance := s.IBCInstances[0].(NewIBCInstance) + instance.Chain = "missing" + s.IBCInstances[0] = instance + }, + want: "references unknown Chain \"missing\"", + }, + { + name: "duplicate IBC Connection id", + mutate: func(s *Spec) { + s.Connections = append(s.Connections, existingConnectionSpec()) + }, + want: "duplicate IBC Connection id \"connection-ab\"", + }, + { + name: "IBC Client references unknown IBC Instance", + mutate: func(s *Spec) { + connection := s.Connections[0] + client := connection.B.(NewClient) + client.IBCInstance = "missing" + connection.B = client + s.Connections[0] = connection + }, + want: "references unknown IBC Instance \"missing\"", + }, + { + name: "IBC Client identity is globally unique", + mutate: func(s *Spec) { + s.Connections = append(s.Connections, ConnectionSpec{ + ID: "connection-2", + A: NewClient{ + ID: "client-a", IBCInstance: "ibc-a", Authority: "connect-a", MinRequiredSignatures: 1, + }, + B: ExistingClient{ + ID: "client-c", IBCInstance: "ibc-b", Locator: "client-11", + }, + }) + }, + want: "duplicate IBC Client id \"client-a\"", + }, + { + name: "Attestor references known IBC Client", + mutate: func(s *Spec) { + s.Attestors[0].Client = "missing" + }, + want: "Attestor \"attestor-1\" references unknown IBC Client \"missing\"", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec := validSpec() + tc.mutate(&spec) + require.ErrorContains(t, spec.validate(), tc.want) + }) + } +} + +func TestSpecValidateAcceptsRelayer(t *testing.T) { + spec := validSpec() + spec.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a", + }} + require.NoError(t, spec.validate()) + + // Multiple Relayers may serve the same Connection. + spec.Relayers = append(spec.Relayers, RelayerSpec{ + ID: "relayer-2", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-b", + }) + require.NoError(t, spec.validate()) +} + +func TestSpecValidateRelayerVariants(t *testing.T) { + tests := []struct { + name string + mutate func(*Spec) + want string + }{ + { + name: "relayer id is required", + mutate: func(s *Spec) { + s.Relayers = []RelayerSpec{{Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a"}} + }, + want: "Relayer id is required", + }, + { + name: "duplicate relayer id", + mutate: func(s *Spec) { + s.Relayers = []RelayerSpec{ + {ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a"}, + {ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-b"}, + } + }, + want: `duplicate Relayer id "relayer-1"`, + }, + { + name: "relayer connection is required", + mutate: func(s *Spec) { s.Relayers = []RelayerSpec{{ID: "relayer-1", Authority: "relay-a"}} }, + want: `Relayer "relayer-1" must serve at least one IBC Connection`, + }, + { + name: "relayer references unknown connection", + mutate: func(s *Spec) { + s.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"missing"}, Authority: "relay-a", + }} + }, + want: `Relayer "relayer-1" references unknown IBC Connection "missing"`, + }, + { + name: "relayer references a connection more than once", + mutate: func(s *Spec) { + s.Relayers = []RelayerSpec{ + { + ID: "relayer-1", + Connections: []ConnectionID{"connection-ab", "connection-ab"}, + Authority: "relay-a", + }, + } + }, + want: `Relayer "relayer-1" references IBC Connection "connection-ab" more than once`, + }, + { + name: "relayer authority is required", + mutate: func(s *Spec) { + s.Relayers = []RelayerSpec{{ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}}} + }, + want: `Relayer "relayer-1" authority is required`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec := validSpec() + tc.mutate(&spec) + require.ErrorContains(t, spec.validate(), tc.want) + }) + } +} + +func TestSpecValidateSigningKeyAuthorityGuard(t *testing.T) { + // A standalone (process) Attestor may carry a signing-key override: the fault + // injection is honored on its own `ibc attestor run` process. + standalone := validSpec() + standalone.Attestors[0].SigningKeyAuthority = "forged-key" + standalone.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a", + }} + require.NoError(t, standalone.validate()) + + // The same override on an Attestor folded into an EmbedAttestors Relayer is + // silently ineffective, so validation rejects it. + embedded := validSpec() + embedded.Attestors[0].SigningKeyAuthority = "forged-key" + embedded.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a", EmbedAttestors: true, + }} + require.ErrorContains(t, embedded.validate(), + `Attestor "attestor-1" sets SigningKeyAuthority but is folded into an EmbedAttestors Relayer`) +} + +func TestSpecSnapshotOwnsRelayers(t *testing.T) { + spec := validSpec() + spec.Relayers = []RelayerSpec{{ + ID: "relayer-1", Connections: []ConnectionID{"connection-ab"}, Authority: "relay-a", + }} + snapshot := spec.snapshot() + spec.Relayers[0].ID = "changed" + require.Equal(t, RelayerID("relayer-1"), snapshot.Relayers[0].ID) + + // The snapshot deep-copies each Relayer's Connections slice, so mutating the + // caller's backing array cannot reach the retained copy. + spec.Relayers[0].Connections[0] = "changed-connection" + require.Equal(t, []ConnectionID{"connection-ab"}, snapshot.Relayers[0].Connections) +} + +func TestSpecValidateConnectionPair(t *testing.T) { + t.Run("client ids differ", func(t *testing.T) { + spec := validSpec() + connection := spec.Connections[0] + clientA := connection.A.(NewClient) + clientB := connection.B.(NewClient) + clientB.ID = clientA.ID + connection.B = clientB + spec.Connections[0] = connection + require.ErrorContains(t, spec.validate(), "clients must have distinct ids") + }) + + t.Run("IBC Instances differ", func(t *testing.T) { + spec := validSpec() + connection := spec.Connections[0] + clientA := connection.A.(NewClient) + clientB := connection.B.(NewClient) + clientB.IBCInstance = clientA.IBCInstance + connection.B = clientB + spec.Connections[0] = connection + require.ErrorContains(t, spec.validate(), "clients must belong to distinct IBC Instances") + }) +} + +func TestSpecValidateRejectsPointerClientDeclaration(t *testing.T) { + spec := validSpec() + client := spec.Connections[0].A.(NewClient) + spec.Connections[0].A = &client + require.ErrorContains( + t, + spec.validate(), + "unsupported declaration *environment.NewClient; use a concrete value", + ) +} + +func TestSpecValidateDoesNotMutate(t *testing.T) { + spec := validSpec() + want := validSpec() + require.NoError(t, spec.validate()) + require.True(t, reflect.DeepEqual(want, spec)) +} + +func TestSpecValidateRejectsUnrepresentableAnvilTiming(t *testing.T) { + spec := validSpec() + spec.Chains[0] = ManagedAnvil{ID: "chain-a", EVMChainID: 31337, BlockInterval: 1500 * time.Millisecond} + require.ErrorContains(t, spec.validate(), "block interval must be a whole number of seconds") +} + +func TestSpecSnapshotOwnsCollections(t *testing.T) { + spec := validSpec() + snapshot := spec.snapshot() + + spec.Chains[0] = ManagedAnvil{ID: "changed", EVMChainID: 8} + spec.IBCInstances[0] = ExistingIBCInstance{ID: "changed"} + spec.Connections[0] = ConnectionSpec{ID: "changed"} + spec.Attestors[0].ID = "changed" + + require.Equal(t, ChainID("chain-a"), snapshot.Chains[0].chainID()) + require.Equal(t, IBCInstanceID("ibc-a"), snapshot.IBCInstances[0].ibcInstanceID()) + require.Equal(t, ConnectionID("connection-ab"), snapshot.Connections[0].ID) + require.Equal(t, AttestorID("attestor-1"), snapshot.Attestors[0].ID) +} + +func validSpec() Spec { + return Spec{ + Chains: []ChainSpec{ + ManagedAnvil{ID: "chain-a", EVMChainID: 31337}, + AttachedEVM{ + ID: "chain-b", + EVMChainID: 31338, + Endpoint: "chain-b-rpc", + Timing: Timing{ + BlockInterval: 2 * time.Second, + CompletionBudget: 40 * time.Second, + SettleWindow: 4 * time.Second, + PollInterval: 250 * time.Millisecond, + }, + }, + }, + IBCInstances: []IBCInstanceSpec{ + NewIBCInstance{ID: "ibc-a", Chain: "chain-a", Authority: "deploy-a"}, + ExistingIBCInstance{ID: "ibc-b", Chain: "chain-b", Locator: "0xibc-b"}, + }, + Connections: []ConnectionSpec{ + { + ID: "connection-ab", + A: NewClient{ + ID: "client-a", IBCInstance: "ibc-a", Authority: "connect-a", MinRequiredSignatures: 1, + }, + B: NewClient{ + ID: "client-b", IBCInstance: "ibc-b", Authority: "connect-b", MinRequiredSignatures: 1, + }, + }, + }, + Attestors: []AttestorSpec{ + {ID: "attestor-1", Client: "client-a", Authority: "attest-a"}, + {ID: "attestor-2", Client: "client-b", Authority: "attest-b"}, + }, + } +} + +func existingConnectionSpec() ConnectionSpec { + return ConnectionSpec{ + ID: "connection-ab", + A: ExistingClient{ID: "client-a", IBCInstance: "ibc-a", Locator: "client-7"}, + B: ExistingClient{ID: "client-b", IBCInstance: "ibc-b", Locator: "client-9"}, + } +} + +func makeChainAAttached(spec *Spec) { + spec.Chains[0] = AttachedEVM{ + ID: "chain-a", + EVMChainID: 31337, + Endpoint: "chain-a-rpc", + Timing: spec.Chains[1].(AttachedEVM).Timing, + } +} diff --git a/e2e/internal/harness/environment/start.go b/e2e/internal/harness/environment/start.go new file mode 100644 index 000000000..d05723408 --- /dev/null +++ b/e2e/internal/harness/environment/start.go @@ -0,0 +1,437 @@ +package environment + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/anvil" + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/besu" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink" +) + +const ( + besuBlockPeriod = 2 * time.Second +) + +// Start realizes every supported declaration and attempts to stop acquired local +// resources before returning an error. On-chain transactions mined before a +// later failure remain on their host chains. +func Start(ctx context.Context, spec Spec, runtime Runtime) (*Environment, error) { + return start(ctx, spec, runtime, productionDrivers()) +} + +type drivers struct { + validatePrerequisites func(Spec, Runtime) error + acquireChain func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) + acquireIBCInstance func(context.Context, IBCInstanceSpec, *Chain, Runtime) (*IBCInstance, error) + prepareConnections func(context.Context, Spec, connectionDependencies, Runtime) (connectionDependencies, error) + acquireConnection func(context.Context, ConnectionSpec, connectionDependencies, Runtime) (*Connection, error) + acquireAttestor func(context.Context, AttestorSpec, attestorDependencies, Runtime, workspace) (attestorAcquisition, error) + acquireRelayer func(context.Context, RelayerSpec, relayerDependencies, Runtime, workspace) (relayerAcquisition, error) +} + +type chainAcquisition struct { + chain *Chain + description string + release func(context.Context) error +} + +type attestorAcquisition struct { + attestor *Attestor + description string + release func(context.Context) error +} + +type relayerAcquisition struct { + relayer *Relayer + description string + release func(context.Context) error +} + +func start(ctx context.Context, spec Spec, runtime Runtime, d drivers) (*Environment, error) { + spec = spec.snapshot() + runtime = runtime.snapshot() + if err := spec.validate(); err != nil { + return nil, err + } + if err := validateRuntime(spec, runtime); err != nil { + return nil, err + } + if d.validatePrerequisites != nil { + if err := d.validatePrerequisites(spec, runtime); err != nil { + return nil, err + } + } + + ws, err := newWorkspace() + if err != nil { + return nil, err + } + effects := &effectJournal{} + + chains, startErr := acquireChains(ctx, spec.Chains, runtime, ws, d, effects) + if startErr != nil { + return nil, abortStart(ctx, startErr, ws, effects) + } + lease := &environmentLease{} + for _, chain := range chains { + chain.bindLease(lease) + } + + instances, startErr := acquireIBCInstances(ctx, spec.IBCInstances, chains, runtime, d) + if startErr != nil { + return nil, abortStart(ctx, startErr, ws, effects) + } + + connections, clients, startErr := acquireConnections(ctx, spec, instances, runtime, d) + if startErr != nil { + return nil, abortStart(ctx, startErr, ws, effects) + } + + attestors, startErr := acquireAttestors(ctx, spec, instances, clients, runtime, ws, d, effects) + if startErr != nil { + return nil, abortStart(ctx, startErr, ws, effects) + } + + // Relayers are acquired after Attestors so cleanup, which releases effects in + // reverse acquisition order, stops Relayers before the Attestors and Chains + // that packets in flight still depend on during drain. + relayers, startErr := acquireRelayers(ctx, spec, connections, attestors, runtime, ws, d, effects) + if startErr != nil { + return nil, abortStart(ctx, startErr, ws, effects) + } + + return &Environment{ + chains: chains, + instances: instances, + connections: connections, + clients: clients, + attestors: attestors, + relayers: relayers, + runtime: runtime, + effects: effects, + ws: ws, + lease: lease, + }, nil +} + +func abortStart( + ctx context.Context, + cause error, + ws workspace, + effects *effectJournal, +) error { + // Adapters provide bounded Stop operations. Let them complete after startup + // cancellation. Runtime-private files are + // always removed; only the separately rooted diagnostics directory may be + // retained when cleanup fails. + cleanupErrs := effects.cleanup(context.WithoutCancel(ctx)) + if removeErr := ws.removePrivate(); removeErr != nil { + cleanupErrs = append( + cleanupErrs, + fmt.Errorf("environment cleanup private workspace removal failed: %w", removeErr), + ) + } + if len(cleanupErrs) == 0 { + if removeErr := ws.removeDiagnostics(); removeErr != nil { + cleanupErrs = append( + cleanupErrs, + fmt.Errorf("environment cleanup diagnostics removal failed: %w", removeErr), + ) + } + } + diagnosticsDir := "" + if len(cleanupErrs) != 0 { + diagnosticsDir = ws.diagnosticsDir + } + return newStartError(cause, diagnosticsDir, cleanupErrs...) +} + +func validateRuntime(spec Spec, runtime Runtime) error { + requiredAuthorities := make(map[AuthorityID]struct{}) + for _, declaration := range spec.Chains { + switch chain := declaration.(type) { + case ManagedAnvil: + case ManagedBesu: + case AttachedEVM: + endpoint, ok := runtime.endpoint(chain.Endpoint) + if !ok || endpoint.RPCURL == "" { + return fmt.Errorf("environment: no runtime endpoint binding for %q", chain.Endpoint) + } + default: + return fmt.Errorf("environment: unsupported Chain declaration %T", declaration) + } + } + for _, declaration := range spec.IBCInstances { + switch instance := declaration.(type) { + case NewIBCInstance: + requiredAuthorities[instance.Authority] = struct{}{} + case ExistingIBCInstance: + if !common.IsHexAddress(string(instance.Locator)) { + return fmt.Errorf("environment: IBC Instance locator %q is not an EVM address", instance.Locator) + } + } + } + for _, connection := range spec.Connections { + for _, declaration := range []ClientSpec{connection.A, connection.B} { + if client, ok := declaration.(NewClient); ok { + requiredAuthorities[client.Authority] = struct{}{} + } + } + } + for _, attestor := range spec.Attestors { + requiredAuthorities[attestor.Authority] = struct{}{} + if attestor.SigningKeyAuthority != "" { + requiredAuthorities[attestor.SigningKeyAuthority] = struct{}{} + } + } + for _, relayer := range spec.Relayers { + requiredAuthorities[relayer.Authority] = struct{}{} + } + for authority := range requiredAuthorities { + if _, err := runtime.evmAccount(authority); err != nil { + return err + } + } + + newInstances := make(map[IBCInstanceID]NewIBCInstance) + for _, declaration := range spec.IBCInstances { + if instance, ok := declaration.(NewIBCInstance); ok { + newInstances[instance.ID] = instance + } + } + for _, connection := range spec.Connections { + for _, declaration := range []ClientSpec{connection.A, connection.B} { + client, ok := declaration.(NewClient) + if !ok { + continue + } + instance, isNew := newInstances[client.IBCInstance] + if !isNew { + continue + } + instanceAuthority, _ := runtime.evmAccount(instance.Authority) + clientAuthority, _ := runtime.evmAccount(client.Authority) + if instanceAuthority.Address() != clientAuthority.Address() { + return fmt.Errorf( + "environment: new IBC Client %q authority must resolve to the new IBC Instance %q admin address", + client.ID, + instance.ID, + ) + } + } + } + + type attestorUse struct { + id AttestorID + client ClientID + } + // Solidity IBC v3 attestations do not yet include domain separation. Reusing one + // signer across Clients would allow the same signed bytes to be replayed in + // another Client context, so signer isolation is a graph-wide invariant. + attestorAddresses := make(map[string]attestorUse, len(spec.Attestors)) + for _, attestor := range spec.Attestors { + account, _ := runtime.evmAccount(attestor.Authority) + address := account.Address().Hex() + if previous, exists := attestorAddresses[address]; exists { + return fmt.Errorf( + "environment: Attestors %q for IBC Client %q and %q for IBC Client %q resolve to the same signer address", + previous.id, + previous.client, + attestor.ID, + attestor.Client, + ) + } + attestorAddresses[address] = attestorUse{id: attestor.ID, client: attestor.Client} + } + return nil +} + +func acquireChains( + ctx context.Context, + declarations []ChainSpec, + runtime Runtime, + ws workspace, + d drivers, + effects *effectJournal, +) (map[ChainID]*Chain, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + acquired := make([]chainAcquisition, len(declarations)) + errs := make([]error, len(declarations)) + var wg sync.WaitGroup + for i, declaration := range declarations { + wg.Add(1) + go func() { + defer wg.Done() + acquisition, err := d.acquireChain(ctx, declaration, runtime, ws) + if err != nil { + errs[i] = fmt.Errorf("start Chain %q failed: %w", declaration.chainID(), err) + cancel() + return + } + + effects.append(cleanupEffect{ + description: acquisition.description, + release: acquisition.release, + }) + acquired[i] = acquisition + }() + } + wg.Wait() + + if err := errors.Join(errs...); err != nil { + return nil, err + } + chains := make(map[ChainID]*Chain, len(acquired)) + for _, acquisition := range acquired { + chains[acquisition.chain.ID()] = acquisition.chain + } + return chains, nil +} + +func productionDrivers() drivers { + return drivers{ + validatePrerequisites: validateProductionPrerequisites, + acquireChain: acquireChain, + acquireIBCInstance: acquireIBCInstance, + prepareConnections: prepareConnections, + acquireConnection: acquireConnection, + acquireAttestor: acquireAttestor, + acquireRelayer: acquireRelayer, + } +} + +func validateProductionPrerequisites(spec Spec, _ Runtime) error { + if len(spec.Attestors) == 0 { + return nil + } + path, err := filepath.Abs(ibclink.ResolvedBin()) + if err != nil { + return fmt.Errorf("environment: resolve IBC Link binary prerequisite: %w", err) + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("environment: IBC Link binary prerequisite %q: %w", path, err) + } + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return fmt.Errorf("environment: IBC Link binary prerequisite %q is not an executable file", path) + } + return nil +} + +func acquireChain( + ctx context.Context, + declaration ChainSpec, + runtime Runtime, + ws workspace, +) (chainAcquisition, error) { + switch spec := declaration.(type) { + case ManagedAnvil: + return acquireAnvil(ctx, spec, ws) + case ManagedBesu: + return acquireBesu(ctx, spec, ws) + case AttachedEVM: + return attachEVM(ctx, spec, runtime) + default: + return chainAcquisition{}, fmt.Errorf("unsupported Chain declaration %T", declaration) + } +} + +func acquireAnvil(ctx context.Context, spec ManagedAnvil, ws workspace) (chainAcquisition, error) { + adapter, err := anvil.Start(ctx, anvil.Spec{ + ID: string(spec.ID), + ChainID: spec.EVMChainID, + LogPath: filepath.Join(ws.diagnosticsDir, "anvil-"+resourcePathToken(string(spec.ID))+".log"), + RunID: ws.runID, + BlockTime: spec.BlockInterval, + }) + if err != nil { + return chainAcquisition{}, err + } + + timing := instantTiming() + if spec.BlockInterval > 0 { + timing = blockTiming(spec.BlockInterval) + } + capabilities := deriveChainCapabilities(spec) + resolved := &Chain{ + id: spec.ID, + evmChainID: spec.EVMChainID, + rpcURL: adapter.RPCURL(), + timing: timing, + impl: adapter, + } + if capabilities.miningControl { + resolved.mining = &Mining{controller: adapter} + } + if capabilities.nodeLifecycle { + resolved.node = &NodeLifecycle{controller: adapter} + } + resolved.funding = &Funding{controller: adapter} + return chainAcquisition{ + chain: resolved, + description: fmt.Sprintf("stop Chain %q", spec.ID), + release: func(context.Context) error { return adapter.Stop() }, + }, nil +} + +func acquireBesu(ctx context.Context, spec ManagedBesu, ws workspace) (chainAcquisition, error) { + adapter, err := besu.StartQBFT(ctx, besu.Spec{ + ID: string(spec.ID), + ChainID: spec.EVMChainID, + WorkDir: filepath.Join(ws.privateDir, "besu-"+resourcePathToken(string(spec.ID))), + RunID: ws.runID, + BlockPeriod: besuBlockPeriod, + }) + if err != nil { + return chainAcquisition{}, err + } + return chainAcquisition{ + chain: &Chain{ + id: spec.ID, + evmChainID: spec.EVMChainID, + rpcURL: adapter.RPCURL(), + timing: blockTiming(besuBlockPeriod), + impl: adapter, + funding: &Funding{controller: adapter}, + }, + description: fmt.Sprintf("stop Chain %q", spec.ID), + release: func(context.Context) error { return adapter.Stop() }, + }, nil +} + +func attachEVM(ctx context.Context, spec AttachedEVM, runtime Runtime) (chainAcquisition, error) { + endpoint, _ := runtime.endpoint(spec.Endpoint) + adapter, err := evm.ConnectAttached(ctx, evm.AttachedSpec{ + ID: string(spec.ID), + ChainID: spec.EVMChainID, + RPCURL: endpoint.RPCURL, + }) + if err != nil { + return chainAcquisition{}, err + } + return chainAcquisition{ + chain: &Chain{ + id: spec.ID, + evmChainID: spec.EVMChainID, + rpcURL: adapter.RPCURL(), + timing: spec.Timing, + impl: adapter, + }, + description: fmt.Sprintf("close local handle for Chain %q", spec.ID), + release: func(context.Context) error { + adapter.Close() + return nil + }, + }, nil +} diff --git a/e2e/internal/harness/environment/start_integration_test.go b/e2e/internal/harness/environment/start_integration_test.go new file mode 100644 index 000000000..b0c3a0fe9 --- /dev/null +++ b/e2e/internal/harness/environment/start_integration_test.go @@ -0,0 +1,609 @@ +package environment_test + +import ( + "context" + "math/big" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/chain/evm/anvil" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink" + + chainevm "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" +) + +// This deterministic identity is not one of Anvil's provider-default funded accounts. +const testDeployerPrivateKeyHex = "0000000000000000000000000000000000000000000000000000000000000005" + +func TestStartRealizesSolidityIBCConnectionAndAttestors(t *testing.T) { + requireDocker(t) + requireIBCLinkBinary(t) + + const ( + chainA environment.ChainID = "chain-a" + chainB environment.ChainID = "chain-b" + instanceA environment.IBCInstanceID = "ibc-a" + instanceB environment.IBCInstanceID = "ibc-b" + connectionID environment.ConnectionID = "a-b" + clientA environment.ClientID = "client-a" + clientB environment.ClientID = "client-b" + attestorA environment.AttestorID = "attestor-a" + attestorB environment.AttestorID = "attestor-b" + deployer environment.AuthorityID = "deployer" + signerA environment.AuthorityID = "signer-a" + signerB environment.AuthorityID = "signer-b" + ) + // Attestors only sign; they do not need funded accounts. Keeping their + // identities distinct makes the on-chain attestation sets unambiguous. + const ( + signerAKey = "0000000000000000000000000000000000000000000000000000000000000001" + signerBKey = "0000000000000000000000000000000000000000000000000000000000000002" + ) + + spec := environment.Spec{ + Chains: []environment.ChainSpec{ + environment.ManagedAnvil{ID: chainA, EVMChainID: 41337}, + environment.ManagedAnvil{ID: chainB, EVMChainID: 41338}, + }, + IBCInstances: []environment.IBCInstanceSpec{ + environment.NewIBCInstance{ID: instanceA, Chain: chainA, Authority: deployer}, + environment.NewIBCInstance{ID: instanceB, Chain: chainB, Authority: deployer}, + }, + Connections: []environment.ConnectionSpec{{ + ID: connectionID, + A: environment.NewClient{ + ID: clientA, + IBCInstance: instanceA, + Authority: deployer, + MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: clientB, + IBCInstance: instanceB, + Authority: deployer, + MinRequiredSignatures: 1, + }, + }}, + Attestors: []environment.AttestorSpec{ + {ID: attestorA, Client: clientA, Authority: signerA}, + {ID: attestorB, Client: clientB, Authority: signerB}, + }, + } + runtime := environment.Runtime{Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: testDeployerPrivateKeyHex}, + signerA: {PrivateKeyHex: signerAKey}, + signerB: {PrivateKeyHex: signerBKey}, + }} + + env, err := environment.Start(t.Context(), spec, runtime) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, env.Close(context.Background())) }) + + resolvedInstanceA, err := env.IBCInstance(instanceA) + require.NoError(t, err) + resolvedInstanceB, err := env.IBCInstance(instanceB) + require.NoError(t, err) + require.True(t, common.IsHexAddress(string(resolvedInstanceA.Locator()))) + require.True(t, common.IsHexAddress(string(resolvedInstanceB.Locator()))) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(resolvedInstanceA.AccessManagerAddress()))) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(resolvedInstanceB.AccessManagerAddress()))) + + connection, err := env.Connection(connectionID) + require.NoError(t, err) + require.Equal(t, clientA, connection.A().ID()) + require.Equal(t, clientB, connection.B().ID()) + require.NotEmpty(t, connection.A().Locator()) + require.NotEmpty(t, connection.B().Locator()) + require.Equal(t, connection.B().Locator(), connection.A().CounterpartyLocator()) + require.Equal(t, connection.A().Locator(), connection.B().CounterpartyLocator()) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(connection.A().LightClientAddress()))) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(connection.B().LightClientAddress()))) + + serviceA, err := env.Attestor(attestorA) + require.NoError(t, err) + serviceB, err := env.Attestor(attestorB) + require.NoError(t, err) + require.Same(t, connection.A(), serviceA.IBCClient()) + require.Same(t, resolvedInstanceB, serviceA.ObservedIBCInstance()) + require.Same(t, connection.B(), serviceB.IBCClient()) + require.Same(t, resolvedInstanceA, serviceB.ObservedIBCInstance()) + require.NotEqual(t, serviceA.SignerAddress(), serviceB.SignerAddress()) + require.Equal(t, []environment.EVMAddress{serviceA.SignerAddress()}, connection.A().AttestorAddresses()) + require.Equal(t, []environment.EVMAddress{serviceB.SignerAddress()}, connection.B().AttestorAddresses()) + require.EqualValues(t, 1, connection.A().MinRequiredSignatures()) + require.EqualValues(t, 1, connection.B().MinRequiredSignatures()) + + require.NoError(t, env.Close(t.Context())) +} + +func TestStartAttachesExistingSolidityIBCResources(t *testing.T) { + requireDocker(t) + requireIBCLinkBinary(t) + + const ( + chainAEndpoint environment.EndpointBindingID = "chain-a-rpc" + chainBEndpoint environment.EndpointBindingID = "chain-b-rpc" + deployer environment.AuthorityID = "deployer" + signerA environment.AuthorityID = "signer-a" + signerB environment.AuthorityID = "signer-b" + signerC environment.AuthorityID = "signer-c" + ) + const ( + signerAKey = "0000000000000000000000000000000000000000000000000000000000000001" + signerBKey = "0000000000000000000000000000000000000000000000000000000000000002" + signerCKey = "0000000000000000000000000000000000000000000000000000000000000003" + ) + + chainA := startOutOfBandAnvil(t, "existing-chain-a", 42337) + chainB := startOutOfBandAnvil(t, "existing-chain-b", 42338) + deployerAccount, err := chainevm.AccountFromHex(testDeployerPrivateKeyHex) + require.NoError(t, err) + deployerMinimum := new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)) + require.NoError(t, chainA.EnsureEOABalance(t.Context(), deployerAccount.Address(), deployerMinimum)) + require.NoError(t, chainB.EnsureEOABalance(t.Context(), deployerAccount.Address(), deployerMinimum)) + chains := []environment.ChainSpec{ + environment.AttachedEVM{ + ID: "chain-a", EVMChainID: 42337, Endpoint: chainAEndpoint, + Timing: attachedTiming(), + }, + environment.AttachedEVM{ + ID: "chain-b", EVMChainID: 42338, Endpoint: chainBEndpoint, + Timing: attachedTiming(), + }, + } + endpoints := map[environment.EndpointBindingID]environment.EndpointBinding{ + chainAEndpoint: {RPCURL: chainA.RPCURL()}, + chainBEndpoint: {RPCURL: chainB.RPCURL()}, + } + + created, err := environment.Start(t.Context(), environment.Spec{ + Chains: chains, + IBCInstances: []environment.IBCInstanceSpec{ + environment.NewIBCInstance{ID: "created-ibc-a", Chain: "chain-a", Authority: deployer}, + environment.NewIBCInstance{ID: "created-ibc-b", Chain: "chain-b", Authority: deployer}, + }, + Connections: []environment.ConnectionSpec{{ + ID: "created-connection", + A: environment.NewClient{ + ID: "created-client-a", IBCInstance: "created-ibc-a", Authority: deployer, MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: "created-client-b", IBCInstance: "created-ibc-b", Authority: deployer, MinRequiredSignatures: 1, + }, + }}, + Attestors: []environment.AttestorSpec{ + {ID: "created-attestor-a", Client: "created-client-a", Authority: signerA}, + {ID: "created-attestor-b", Client: "created-client-b", Authority: signerB}, + }, + }, environment.Runtime{ + Endpoints: endpoints, + Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: testDeployerPrivateKeyHex}, + signerA: {PrivateKeyHex: signerAKey}, + signerB: {PrivateKeyHex: signerBKey}, + }, + }) + require.NoError(t, err) + + createdInstanceA, err := created.IBCInstance("created-ibc-a") + require.NoError(t, err) + createdInstanceB, err := created.IBCInstance("created-ibc-b") + require.NoError(t, err) + createdConnection, err := created.Connection("created-connection") + require.NoError(t, err) + require.NoError(t, created.Close(t.Context())) + + attached, err := environment.Start(t.Context(), environment.Spec{ + Chains: chains, + IBCInstances: []environment.IBCInstanceSpec{ + environment.ExistingIBCInstance{ + ID: "attached-ibc-a", Chain: "chain-a", Locator: createdInstanceA.Locator(), + }, + environment.ExistingIBCInstance{ + ID: "attached-ibc-b", Chain: "chain-b", Locator: createdInstanceB.Locator(), + }, + }, + Connections: []environment.ConnectionSpec{{ + ID: "attached-connection", + A: environment.ExistingClient{ + ID: "attached-client-a", IBCInstance: "attached-ibc-a", Locator: createdConnection.A().Locator(), + }, + B: environment.ExistingClient{ + ID: "attached-client-b", IBCInstance: "attached-ibc-b", Locator: createdConnection.B().Locator(), + }, + }}, + Attestors: []environment.AttestorSpec{ + {ID: "attached-attestor-a", Client: "attached-client-a", Authority: signerA}, + {ID: "attached-attestor-b", Client: "attached-client-b", Authority: signerB}, + }, + }, environment.Runtime{ + Endpoints: endpoints, + Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + signerA: {PrivateKeyHex: signerAKey}, + signerB: {PrivateKeyHex: signerBKey}, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, attached.Close(context.Background())) }) + + attachedConnection, err := attached.Connection("attached-connection") + require.NoError(t, err) + require.Equal(t, createdConnection.A().Locator(), attachedConnection.A().Locator()) + require.Equal(t, createdConnection.B().Locator(), attachedConnection.B().Locator()) + require.Equal(t, createdConnection.B().Locator(), attachedConnection.A().CounterpartyLocator()) + require.Equal(t, createdConnection.A().Locator(), attachedConnection.B().CounterpartyLocator()) + + require.NoError(t, attached.Close(t.Context())) + + // Reuse the original A end against a fresh B router. Keeping the authored + // Connection, Client, and Instance identities stable reproduces the exact + // reciprocal locator expected by the existing A Client on the new router. + mixed, err := environment.Start(t.Context(), environment.Spec{ + Chains: chains, + IBCInstances: []environment.IBCInstanceSpec{ + environment.ExistingIBCInstance{ + ID: "created-ibc-a", Chain: "chain-a", Locator: createdInstanceA.Locator(), + }, + environment.NewIBCInstance{ + ID: "created-ibc-b", Chain: "chain-b", Authority: deployer, + }, + }, + Connections: []environment.ConnectionSpec{{ + ID: "created-connection", + A: environment.ExistingClient{ + ID: "created-client-a", IBCInstance: "created-ibc-a", Locator: createdConnection.A().Locator(), + }, + B: environment.NewClient{ + ID: "created-client-b", + IBCInstance: "created-ibc-b", + Authority: deployer, + MinRequiredSignatures: 1, + }, + }}, + Attestors: []environment.AttestorSpec{{ + ID: "mixed-attestor-b", Client: "created-client-b", Authority: signerC, + }}, + }, environment.Runtime{ + Endpoints: endpoints, + Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: testDeployerPrivateKeyHex}, + signerC: {PrivateKeyHex: signerCKey}, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, mixed.Close(context.Background())) }) + mixedConnection, err := mixed.Connection("created-connection") + require.NoError(t, err) + require.Equal(t, createdConnection.A().Locator(), mixedConnection.A().Locator()) + require.Equal(t, createdConnection.B().Locator(), mixedConnection.B().Locator()) + require.NoError(t, mixed.Close(t.Context())) + + _, err = chainA.Height(t.Context()) + require.NoError(t, err, "closing both Environments must leave the attached Chain running") + _, err = chainB.Height(t.Context()) + require.NoError(t, err, "closing both Environments must leave the attached Chain running") +} + +func TestStartRealizesSolidityIBCConnectionAcrossAnvilAndBesu(t *testing.T) { + requireDocker(t) + requireIBCLinkBinary(t) + + const ( + deployer environment.AuthorityID = "deployer" + signerA environment.AuthorityID = "signer-a" + signerB environment.AuthorityID = "signer-b" + ) + runtime := environment.Runtime{Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: testDeployerPrivateKeyHex}, + signerA: {PrivateKeyHex: "0000000000000000000000000000000000000000000000000000000000000001"}, + signerB: {PrivateKeyHex: "0000000000000000000000000000000000000000000000000000000000000002"}, + }} + spec := environment.Spec{ + Chains: []environment.ChainSpec{ + environment.ManagedAnvil{ID: "anvil", EVMChainID: 43337}, + environment.ManagedBesu{ID: "besu", EVMChainID: 43338}, + }, + IBCInstances: []environment.IBCInstanceSpec{ + environment.NewIBCInstance{ID: "anvil-ibc", Chain: "anvil", Authority: deployer}, + environment.NewIBCInstance{ID: "besu-ibc", Chain: "besu", Authority: deployer}, + }, + Connections: []environment.ConnectionSpec{{ + ID: "anvil-besu", + A: environment.NewClient{ + ID: "anvil-client", IBCInstance: "anvil-ibc", Authority: deployer, MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: "besu-client", IBCInstance: "besu-ibc", Authority: deployer, MinRequiredSignatures: 1, + }, + }}, + Attestors: []environment.AttestorSpec{ + {ID: "anvil-attestor", Client: "anvil-client", Authority: signerA}, + {ID: "besu-attestor", Client: "besu-client", Authority: signerB}, + }, + } + + env, err := environment.Start(t.Context(), spec, runtime) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, env.Close(context.Background())) }) + + connection, err := env.Connection("anvil-besu") + require.NoError(t, err) + require.Equal(t, connection.B().Locator(), connection.A().CounterpartyLocator()) + require.Equal(t, connection.A().Locator(), connection.B().CounterpartyLocator()) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(connection.A().LightClientAddress()))) + require.NotEqual(t, common.Address{}, common.HexToAddress(string(connection.B().LightClientAddress()))) + require.NoError(t, env.Close(t.Context())) +} + +func TestStartMixedManagedAndAttachedEVM(t *testing.T) { + requireDocker(t) + + const ( + managedID environment.ChainID = "managed" + attachedID environment.ChainID = "attached" + attachedRPC environment.EndpointBindingID = "attached-rpc" + managedChainID = 31357 + attachedChainID = 31358 + ) + + outOfBand, err := anvil.Start(t.Context(), anvil.Spec{ + ID: "out-of-band-attached", + ChainID: attachedChainID, + LogPath: filepath.Join(t.TempDir(), "out-of-band-anvil.log"), + StatePath: filepath.Join(t.TempDir(), "out-of-band-anvil-state.json"), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, outOfBand.Stop()) }) + + spec := environment.Spec{Chains: []environment.ChainSpec{ + environment.ManagedAnvil{ + ID: managedID, + EVMChainID: managedChainID, + }, + environment.AttachedEVM{ + ID: attachedID, + EVMChainID: attachedChainID, + Endpoint: attachedRPC, + Timing: environment.Timing{ + CompletionBudget: 30 * time.Second, + SettleWindow: time.Second, + PollInterval: 100 * time.Millisecond, + }, + }, + }} + runtime := environment.Runtime{Endpoints: map[environment.EndpointBindingID]environment.EndpointBinding{ + attachedRPC: {RPCURL: outOfBand.RPCURL()}, + }} + + env, err := environment.Start(t.Context(), spec, runtime) + require.NoError(t, err) + + managed, err := env.Chain(managedID) + require.NoError(t, err) + attached, err := env.Chain(attachedID) + require.NoError(t, err) + + _, err = managed.Height(t.Context()) + require.NoError(t, err, "managed Chain is ready") + _, err = attached.Height(t.Context()) + require.NoError(t, err, "attached Chain is ready") + + mining, err := managed.Mining() + require.NoError(t, err) + require.NotNil(t, mining) + managedMining := mining + node, err := managed.NodeLifecycle() + require.NoError(t, err) + require.NotNil(t, node) + managedNode := node + + mining, err = attached.Mining() + require.Nil(t, mining) + require.ErrorIs(t, err, environment.ErrCapabilityUnavailable) + node, err = attached.NodeLifecycle() + require.Nil(t, node) + require.ErrorIs(t, err, environment.ErrCapabilityUnavailable) + + funding, err := managed.Funding() + require.NoError(t, err) + managedFunding := funding + target := common.HexToAddress("0x1000000000000000000000000000000000000001") + minimum := big.NewInt(1_000_000_000_000_000_000) + require.NoError(t, funding.EnsureEOABalance(t.Context(), target, minimum)) + evmClient, err := managed.EVM() + require.NoError(t, err) + balance, err := evmClient.BalanceAt(t.Context(), target, nil) + require.NoError(t, err) + require.Equal(t, minimum, balance) + require.NoError(t, funding.EnsureEOABalance(t.Context(), target, big.NewInt(1))) + balance, err = evmClient.BalanceAt(t.Context(), target, nil) + require.NoError(t, err) + require.Equal(t, minimum, balance, "ensuring a lower minimum must not reduce the balance") + require.ErrorContains( + t, + funding.EnsureEOABalance(t.Context(), common.Address{}, minimum), + "EOA address is zero", + ) + requireCodeBearingEOAFundingRejected(t, managed, funding, minimum) + + headerBeforeRestart, err := evmClient.HeaderByNumber(t.Context(), nil) + require.NoError(t, err) + require.NoError(t, managedNode.Stop(t.Context())) + require.NoError(t, managedNode.Start(t.Context())) + headerAfterRestart, err := evmClient.HeaderByNumber(t.Context(), nil) + require.NoError(t, err, "an EVM handle created before restart must resolve the replacement client") + require.GreaterOrEqual(t, headerAfterRestart.Number.Uint64(), headerBeforeRestart.Number.Uint64()) + + funding, err = attached.Funding() + require.Nil(t, funding) + require.ErrorIs(t, err, environment.ErrCapabilityUnavailable) + attachedEVM, err := attached.EVM() + require.NoError(t, err) + _, err = attachedEVM.HeaderByNumber(t.Context(), nil) + require.NoError(t, err) + + managedRPC := managed.RPCURL() + require.NoError(t, env.Close(t.Context())) + require.NoError(t, env.Close(t.Context()), "Close is idempotent") + + _, err = attachedEVM.HeaderByNumber(t.Context(), nil) + require.ErrorIs(t, err, environment.ErrEnvironmentClosed) + _, err = managed.Height(t.Context()) + require.ErrorIs(t, err, environment.ErrEnvironmentClosed) + require.ErrorIs(t, managedMining.Mine(t.Context(), 1), environment.ErrEnvironmentClosed) + require.ErrorIs(t, managedNode.Start(t.Context()), environment.ErrEnvironmentClosed) + require.ErrorIs( + t, + managedFunding.EnsureEOABalance(t.Context(), target, minimum), + environment.ErrEnvironmentClosed, + ) + + assertRPCUnavailable(t, managedRPC) + _, err = outOfBand.Height(t.Context()) + require.NoError(t, err, "closing the Environment must leave the attached Chain running") +} + +func TestStartManagedBesu(t *testing.T) { + requireDocker(t) + + const chainID environment.ChainID = "besu" + env, err := environment.Start(t.Context(), environment.Spec{Chains: []environment.ChainSpec{ + environment.ManagedBesu{ID: chainID, EVMChainID: 32357}, + }}, environment.Runtime{}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, env.Close(context.Background())) }) + + chain, err := env.Chain(chainID) + require.NoError(t, err) + _, err = chain.Height(t.Context()) + require.NoError(t, err) + require.Equal(t, 2*time.Second, chain.Timing().BlockInterval) + _, err = chain.Mining() + require.ErrorIs(t, err, environment.ErrCapabilityUnavailable) + _, err = chain.NodeLifecycle() + require.ErrorIs(t, err, environment.ErrCapabilityUnavailable) + + funding, err := chain.Funding() + require.NoError(t, err) + target := common.HexToAddress("0x2000000000000000000000000000000000000002") + minimum := big.NewInt(1_000_000_000_000_000_000) + require.NoError(t, funding.EnsureEOABalance(t.Context(), target, minimum)) + require.NoError(t, funding.EnsureEOABalance(t.Context(), target, new(big.Int).Mul(minimum, big.NewInt(2)))) + evmClient, err := chain.EVM() + require.NoError(t, err) + balance, err := evmClient.BalanceAt(t.Context(), target, nil) + require.NoError(t, err) + require.Equal(t, new(big.Int).Mul(minimum, big.NewInt(2)), balance) + require.ErrorContains( + t, + funding.EnsureEOABalance(t.Context(), common.Address{}, minimum), + "EOA address is zero", + ) + requireCodeBearingEOAFundingRejected(t, chain, funding, minimum) + + rpcURL := chain.RPCURL() + require.NoError(t, env.Close(t.Context())) + assertRPCUnavailable(t, rpcURL) +} + +func requireDocker(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("Docker is unavailable: %v", err) + } + if output, err := exec.CommandContext(t.Context(), "docker", "info").CombinedOutput(); err != nil { + t.Skipf("Docker daemon is unavailable: %v (%s)", err, output) + } +} + +func requireCodeBearingEOAFundingRejected( + t *testing.T, + chain *environment.Chain, + funding *environment.Funding, + minimum *big.Int, +) { + t.Helper() + + sender, err := chainevm.NewAccount() + require.NoError(t, err) + require.NoError(t, funding.EnsureEOABalance(t.Context(), sender.Address(), minimum)) + + evmAccess, err := chain.EVM() + require.NoError(t, err) + // The init code returns a single STOP opcode as the runtime contract. + initCode := common.FromHex("0x6001600c60003960016000f300") + receipt, err := evmAccess.BroadcastTx(t.Context(), sender, nil, initCode, nil) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, receipt.ContractAddress) + + before, err := evmAccess.BalanceAt(t.Context(), receipt.ContractAddress, nil) + require.NoError(t, err) + require.ErrorContains( + t, + funding.EnsureEOABalance(t.Context(), receipt.ContractAddress, minimum), + "has contract code and is not an EOA", + ) + after, err := evmAccess.BalanceAt(t.Context(), receipt.ContractAddress, nil) + require.NoError(t, err) + require.Equal(t, before, after, "rejected contract funding must not mutate its balance") +} + +func requireIBCLinkBinary(t *testing.T) { + t.Helper() + path := ibclink.ResolvedBin() + info, err := os.Stat(path) + if err != nil { + if os.Getenv("IBC_BIN") != "" { + require.NoError(t, err, "explicit IBC_BIN is unavailable at %s", path) + } + t.Skipf("IBC Link binary is unavailable at %s: %v; run `make -C link build`", path, err) + } + if info.Mode()&0o111 == 0 { + if os.Getenv("IBC_BIN") != "" { + t.Fatalf("explicit IBC_BIN is not executable: %s", path) + } + t.Skipf("IBC Link binary is not executable: %s; run `make -C link build`", path) + } +} + +func startOutOfBandAnvil(t *testing.T, id string, chainID uint64) *anvil.Chain { + t.Helper() + chain, err := anvil.Start(t.Context(), anvil.Spec{ + ID: id, + ChainID: chainID, + LogPath: filepath.Join(t.TempDir(), id+".log"), + StatePath: filepath.Join(t.TempDir(), id+"-state.json"), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, chain.Stop()) }) + return chain +} + +func attachedTiming() environment.Timing { + return environment.Timing{ + CompletionBudget: 30 * time.Second, + SettleWindow: time.Second, + PollInterval: 100 * time.Millisecond, + } +} + +func assertRPCUnavailable(t *testing.T, rpcURL string) { + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + client, err := ethclient.DialContext(ctx, rpcURL) + if err == nil { + defer client.Close() + _, err = client.BlockNumber(ctx) + } + require.Error(t, err, "managed Chain RPC remains reachable after Environment.Close") +} diff --git a/e2e/internal/harness/environment/start_test.go b/e2e/internal/harness/environment/start_test.go new file mode 100644 index 000000000..4df26df07 --- /dev/null +++ b/e2e/internal/harness/environment/start_test.go @@ -0,0 +1,438 @@ +package environment + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + chainimpl "github.com/cosmos/ibc/e2e/internal/harness/chain" + chainevm "github.com/cosmos/ibc/e2e/internal/harness/chain/evm" +) + +func TestStartValidatesRuntimeBeforeAcquisition(t *testing.T) { + called := false + spec := Spec{Chains: []ChainSpec{AttachedEVM{ + ID: "attached", EVMChainID: 31338, Endpoint: "missing", Timing: testTiming(), + }}} + _, err := start(t.Context(), spec, Runtime{}, drivers{ + acquireChain: func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) { + called = true + return chainAcquisition{}, nil + }, + }) + require.ErrorContains(t, err, `no runtime endpoint binding for "missing"`) + require.False(t, called) +} + +func TestStartRejectsInvalidExistingInstanceLocatorBeforeAcquisition(t *testing.T) { + called := false + spec := Spec{ + Chains: []ChainSpec{AttachedEVM{ + ID: "anvil", EVMChainID: 31337, Endpoint: "anvil-rpc", Timing: testTiming(), + }}, + IBCInstances: []IBCInstanceSpec{ + ExistingIBCInstance{ID: "ibc", Chain: "anvil", Locator: "not-an-address"}, + }, + } + _, err := start(t.Context(), spec, Runtime{Endpoints: map[EndpointBindingID]EndpointBinding{ + "anvil-rpc": {RPCURL: "http://127.0.0.1:8545"}, + }}, drivers{ + acquireChain: func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) { + called = true + return chainAcquisition{}, nil + }, + }) + require.ErrorContains(t, err, `IBC Instance locator "not-an-address" is not an EVM address`) + require.False(t, called) +} + +func TestStartRejectsAttestorSignerReuseAcrossClientsBeforeAcquisition(t *testing.T) { + called := false + spec := Spec{ + Chains: []ChainSpec{ + AttachedEVM{ID: "chain-a", EVMChainID: 31337, Endpoint: "chain-a-rpc", Timing: testTiming()}, + AttachedEVM{ID: "chain-b", EVMChainID: 31338, Endpoint: "chain-b-rpc", Timing: testTiming()}, + }, + IBCInstances: []IBCInstanceSpec{ + ExistingIBCInstance{ID: "ibc-a", Chain: "chain-a", Locator: "0x1000000000000000000000000000000000000001"}, + ExistingIBCInstance{ID: "ibc-b", Chain: "chain-b", Locator: "0x2000000000000000000000000000000000000002"}, + }, + Connections: []ConnectionSpec{{ + ID: "connection-ab", + A: ExistingClient{ID: "client-a", IBCInstance: "ibc-a", Locator: "existing-a"}, + B: ExistingClient{ID: "client-b", IBCInstance: "ibc-b", Locator: "existing-b"}, + }}, + Attestors: []AttestorSpec{ + {ID: "attestor-a", Client: "client-a", Authority: "signer-a"}, + {ID: "attestor-b", Client: "client-b", Authority: "signer-b"}, + }, + } + runtime := Runtime{ + Endpoints: map[EndpointBindingID]EndpointBinding{ + "chain-a-rpc": {RPCURL: "http://127.0.0.1:8545"}, + "chain-b-rpc": {RPCURL: "http://127.0.0.1:8546"}, + }, + Authorities: map[AuthorityID]EVMAuthority{ + "signer-a": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + "signer-b": {PrivateKeyHex: testPrimaryPrivateKeyHex}, + }, + } + + _, err := start(t.Context(), spec, runtime, drivers{ + acquireChain: func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) { + called = true + return chainAcquisition{}, nil + }, + }) + require.ErrorContains( + t, + err, + `Attestors "attestor-a" for IBC Client "client-a" and "attestor-b" for IBC Client "client-b" resolve to the same signer address`, + ) + require.False(t, called) +} + +func TestStartRejectsUnauthorizedNewClientBeforeAcquisition(t *testing.T) { + called := false + spec := mixedProtocolSpec() + runtime := mixedProtocolRuntime() + runtime.Authorities["client-owner"] = EVMAuthority{PrivateKeyHex: testSecondaryPrivateKeyHex} + + _, err := start(t.Context(), spec, runtime, drivers{ + acquireChain: func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) { + called = true + return chainAcquisition{}, nil + }, + }) + require.ErrorContains( + t, + err, + `new IBC Client "client-a" authority must resolve to the new IBC Instance "ibc-a" admin address`, + ) + require.False(t, called) +} + +func TestProductionPrerequisitesRequireExecutableAttestorBinary(t *testing.T) { + spec := Spec{Attestors: []AttestorSpec{{ID: "attestor-a"}}} + path := filepath.Join(t.TempDir(), "ibc") + t.Setenv("IBC_BIN", path) + + err := validateProductionPrerequisites(spec, Runtime{}) + require.ErrorContains(t, err, "no such file or directory") + + require.NoError(t, os.WriteFile(path, []byte("binary placeholder"), 0o600)) + err = validateProductionPrerequisites(spec, Runtime{}) + require.ErrorContains(t, err, "not an executable file") + + require.NoError(t, os.Chmod(path, 0o700)) + require.NoError(t, validateProductionPrerequisites(spec, Runtime{})) +} + +func TestStartFailureIsAtomicAndCleansAcquiredEffects(t *testing.T) { + primary := errors.New("second Chain failed with endpoint detail") + firstAcquired := make(chan struct{}) + var releases atomic.Int32 + + spec := Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "first", EVMChainID: 31337}, + ManagedAnvil{ID: "second", EVMChainID: 31338}, + }} + env, err := start(t.Context(), spec, Runtime{}, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + switch declaration.chainID() { + case "first": + close(firstAcquired) + return fakeAcquisition( + "first", + func(context.Context) error { + releases.Add(1) + return nil + }, + ), nil + case "second": + <-firstAcquired + return chainAcquisition{}, primary + default: + panic("unexpected Chain") + } + }, + }) + require.Nil(t, env) + var startErr *StartError + require.ErrorAs(t, err, &startErr) + require.ErrorIs(t, err, primary) + require.Contains(t, err.Error(), "endpoint detail") + require.EqualValues(t, 1, releases.Load()) +} + +func TestStartFailureReportsCleanupFailure(t *testing.T) { + primary := errors.New("acquire failed") + releaseErr := errors.New("release failed with details") + firstAcquired := make(chan struct{}) + var privateDir, diagnosticsDir string + spec := Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "first", EVMChainID: 31337}, + ManagedAnvil{ID: "second", EVMChainID: 31338}, + }} + + _, err := start(t.Context(), spec, Runtime{}, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, ws workspace) (chainAcquisition, error) { + if declaration.chainID() == "first" { + privateDir = ws.privateDir + diagnosticsDir = ws.diagnosticsDir + close(firstAcquired) + + attestorDir := filepath.Join(privateDir, "attestor", "keys") + if mkdirErr := os.MkdirAll(attestorDir, 0o700); mkdirErr != nil { + return chainAcquisition{}, mkdirErr + } + if writeErr := os.WriteFile( + filepath.Join(attestorDir, "attestor.json"), + []byte(`{"privateKeyBase64":"must-not-be-diagnostic"}`), + 0o600, + ); writeErr != nil { + return chainAcquisition{}, writeErr + } + if writeErr := os.WriteFile( + filepath.Join(privateDir, "attestor", "ibc.yml"), + []byte("signer: private\n"), + 0o600, + ); writeErr != nil { + return chainAcquisition{}, writeErr + } + if writeErr := os.WriteFile( + filepath.Join(diagnosticsDir, "safe.log"), + []byte("safe diagnostic\n"), + 0o600, + ); writeErr != nil { + return chainAcquisition{}, writeErr + } + return fakeAcquisition( + "first", + func(context.Context) error { + return releaseErr + }, + ), nil + } + <-firstAcquired + return chainAcquisition{}, primary + }, + }) + var startErr *StartError + require.ErrorAs(t, err, &startErr) + require.ErrorIs(t, startErr.CleanupError(), releaseErr) + require.Contains(t, err.Error(), "release failed with details") + require.Contains(t, startErr.CleanupError().Error(), "release failed with details") + require.Equal(t, diagnosticsDir, startErr.DiagnosticsDir()) + require.NotEqual(t, privateDir, startErr.DiagnosticsDir()) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(startErr.DiagnosticsDir())) }) + _, statErr := os.Stat(startErr.DiagnosticsDir()) + require.NoError(t, statErr, "cleanup failure retains the diagnostics workspace") + _, statErr = os.Stat(privateDir) + require.ErrorIs(t, statErr, os.ErrNotExist, "startup failure removes runtime-private files") + entries, readErr := os.ReadDir(startErr.DiagnosticsDir()) + require.NoError(t, readErr) + require.Len(t, entries, 1) + require.Equal(t, "safe.log", entries[0].Name()) +} + +func TestEnvironmentCloseSeparatesBorrowedResourceFromOwnedHandle(t *testing.T) { + var managedReleases atomic.Int32 + var attachedReleases atomic.Int32 + spec := Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "managed", EVMChainID: 31337}, + AttachedEVM{ID: "attached", EVMChainID: 31338, Endpoint: "attached-rpc", Timing: testTiming()}, + }} + runtime := Runtime{Endpoints: map[EndpointBindingID]EndpointBinding{ + "attached-rpc": {RPCURL: "http://runtime-only.invalid"}, + }} + + env, err := start(t.Context(), spec, runtime, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + if declaration.chainID() == "managed" { + return fakeAcquisition( + "managed", + func(context.Context) error { + managedReleases.Add(1) + return nil + }, + ), nil + } + return fakeAcquisition( + "attached", + func(context.Context) error { + attachedReleases.Add(1) + return nil + }, + ), nil + }, + }) + require.NoError(t, err) + require.NotNil(t, env) + _, err = env.Chain("missing") + require.ErrorContains(t, err, `no Chain "missing"`) + + require.NoError(t, env.Close(t.Context())) + require.NoError(t, env.Close(t.Context())) + require.EqualValues(t, 1, managedReleases.Load()) + require.EqualValues(t, 1, attachedReleases.Load()) +} + +func TestEnvironmentCloseRetriesOnlyFailedEffects(t *testing.T) { + releaseErr := errors.New("temporary release failure") + var calls atomic.Int32 + spec := Spec{Chains: []ChainSpec{ManagedAnvil{ID: "managed", EVMChainID: 31337}}} + env, err := start(t.Context(), spec, Runtime{}, drivers{ + acquireChain: func(context.Context, ChainSpec, Runtime, workspace) (chainAcquisition, error) { + acquisition := fakeAcquisition( + "managed", + func(context.Context) error { + if calls.Add(1) == 1 { + return releaseErr + } + return nil + }, + ) + acquisition.chain.impl = fakeEVMRuntimeChain{fakeRuntimeChain{id: "managed"}} + return acquisition, nil + }, + }) + require.NoError(t, err) + chain, err := env.Chain("managed") + require.NoError(t, err) + evmAccess, err := chain.EVM() + require.NoError(t, err) + + require.ErrorIs(t, env.Close(t.Context()), releaseErr) + require.ErrorIs(t, evmAccess.WaitPendingTx(t.Context()), ErrEnvironmentClosed) + _, err = chain.Height(t.Context()) + require.ErrorIs(t, err, ErrEnvironmentClosed) + require.NoError(t, env.Close(t.Context())) + require.NoError(t, env.Close(t.Context())) + require.EqualValues(t, 2, calls.Load(), "successful cleanup is not repeated") +} + +func TestStartAcquiresIndependentChainsConcurrently(t *testing.T) { + started := make(chan ChainID, 2) + release := make(chan struct{}) + done := make(chan error, 1) + spec := Spec{Chains: []ChainSpec{ + ManagedAnvil{ID: "a", EVMChainID: 31337}, + ManagedAnvil{ID: "b", EVMChainID: 31338}, + }} + + go func() { + env, err := start(context.Background(), spec, Runtime{}, drivers{ + acquireChain: func(_ context.Context, declaration ChainSpec, _ Runtime, _ workspace) (chainAcquisition, error) { + started <- declaration.chainID() + <-release + return fakeAcquisition( + declaration.chainID(), + func(context.Context) error { + return nil + }, + ), nil + }, + }) + if err == nil { + err = env.Close(context.Background()) + } + done <- err + }() + + seen := map[ChainID]bool{} + for range 2 { + select { + case id := <-started: + seen[id] = true + case <-time.After(time.Second): + t.Fatal("both independent Chain acquisitions did not start concurrently") + } + } + close(release) + require.NoError(t, <-done) + require.Equal(t, map[ChainID]bool{"a": true, "b": true}, seen) +} + +func TestStartSnapshotsRuntimeBindingsBeforeAcquisition(t *testing.T) { + entered := make(chan struct{}) + proceed := make(chan struct{}) + seen := make(chan string, 1) + runtime := Runtime{Endpoints: map[EndpointBindingID]EndpointBinding{ + "rpc": {RPCURL: "http://original.invalid"}, + }} + spec := Spec{Chains: []ChainSpec{AttachedEVM{ + ID: "attached", EVMChainID: 31338, Endpoint: "rpc", Timing: testTiming(), + }}} + done := make(chan error, 1) + go func() { + env, err := start(context.Background(), spec, runtime, drivers{ + acquireChain: func(_ context.Context, _ ChainSpec, snapshot Runtime, _ workspace) (chainAcquisition, error) { + close(entered) + <-proceed + seen <- snapshot.Endpoints["rpc"].RPCURL + return fakeAcquisition( + "attached", + func(context.Context) error { + return nil + }, + ), nil + }, + }) + if err == nil { + err = env.Close(context.Background()) + } + done <- err + }() + + <-entered + runtime.Endpoints["rpc"] = EndpointBinding{RPCURL: "http://mutated.invalid"} + close(proceed) + require.Equal(t, "http://original.invalid", <-seen) + require.NoError(t, <-done) +} + +func fakeAcquisition( + id ChainID, + release func(context.Context) error, +) chainAcquisition { + impl := fakeRuntimeChain{id: string(id)} + return chainAcquisition{ + chain: &Chain{ + id: id, + evmChainID: 1, + rpcURL: "http://rpc.example.invalid", + timing: instantTiming(), + impl: impl, + }, + description: "release Chain " + string(id), + release: release, + } +} + +type fakeRuntimeChain struct{ id string } + +var _ chainimpl.Chain = fakeRuntimeChain{} + +func (c fakeRuntimeChain) ID() string { return c.id } +func (fakeRuntimeChain) RPCURL() string { return "http://rpc.example.invalid" } +func (fakeRuntimeChain) Height(context.Context) (uint64, error) { return 1, nil } + +type fakeEVMRuntimeChain struct{ fakeRuntimeChain } + +func (fakeEVMRuntimeChain) WithEVMClient(use func(*chainevm.EVMClient) error) error { + return use(nil) +} + +func testTiming() Timing { + return Timing{ + BlockInterval: time.Second, CompletionBudget: 20 * time.Second, + SettleWindow: 2 * time.Second, PollInterval: 100 * time.Millisecond, + } +} diff --git a/e2e/internal/harness/environment/test_keys_test.go b/e2e/internal/harness/environment/test_keys_test.go new file mode 100644 index 000000000..16b5229d9 --- /dev/null +++ b/e2e/internal/harness/environment/test_keys_test.go @@ -0,0 +1,8 @@ +package environment + +const ( + // These deterministic identities are deliberately unrelated to the + // provider-default accounts exposed by managed development Chains. + testPrimaryPrivateKeyHex = "0000000000000000000000000000000000000000000000000000000000000003" + testSecondaryPrivateKeyHex = "0000000000000000000000000000000000000000000000000000000000000004" +) diff --git a/e2e/internal/harness/environment/workspace.go b/e2e/internal/harness/environment/workspace.go new file mode 100644 index 000000000..b6843b8f0 --- /dev/null +++ b/e2e/internal/harness/environment/workspace.go @@ -0,0 +1,64 @@ +package environment + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "time" +) + +type workspace struct { + privateDir string + diagnosticsDir string + runID string +} + +func newWorkspace() (workspace, error) { + privateDir, err := os.MkdirTemp("", "ibc-environment-private-") + if err != nil { + return workspace{}, fmt.Errorf("create private environment work directory: %w", err) + } + diagnosticsDir, err := os.MkdirTemp("", "ibc-environment-diagnostics-") + if err != nil { + return workspace{}, errors.Join( + fmt.Errorf("create environment diagnostics directory: %w", err), + removeDirectory("private environment work", privateDir), + ) + } + return workspace{ + privateDir: privateDir, + diagnosticsDir: diagnosticsDir, + runID: fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano()), + }, nil +} + +func (w workspace) remove() error { + return errors.Join(w.removePrivate(), w.removeDiagnostics()) +} + +func (w workspace) removePrivate() error { + return removeDirectory("private environment work", w.privateDir) +} + +func (w workspace) removeDiagnostics() error { + return removeDirectory("environment diagnostics", w.diagnosticsDir) +} + +func removeDirectory(label, path string) error { + if path == "" { + return nil + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove %s directory: %w", label, err) + } + return nil +} + +// resourcePathToken keeps authored identities out of filesystem paths. IDs +// are domain values and may legitimately contain separators or dot segments. +func resourcePathToken(id string) string { + hash := sha256.Sum256([]byte(id)) + return hex.EncodeToString(hash[:8]) +} diff --git a/e2e/internal/harness/environment/workspace_test.go b/e2e/internal/harness/environment/workspace_test.go new file mode 100644 index 000000000..b172d782e --- /dev/null +++ b/e2e/internal/harness/environment/workspace_test.go @@ -0,0 +1,16 @@ +package environment + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResourcePathTokenContainsNoAuthoredPathSegments(t *testing.T) { + token := resourcePathToken("chain/../../outside") + require.Len(t, token, 16) + require.Equal(t, token, filepath.Base(token)) + require.NotContains(t, token, ".") + require.NotContains(t, token, "/") +} diff --git a/e2e/internal/harness/go.mod b/e2e/internal/harness/go.mod new file mode 100644 index 000000000..ace4426fd --- /dev/null +++ b/e2e/internal/harness/go.mod @@ -0,0 +1,143 @@ +module github.com/cosmos/ibc/e2e/internal/harness + +go 1.26.4 + +require ( + connectrpc.com/connect v1.20.0 + github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b + github.com/ethereum/go-ethereum v1.17.4 + github.com/moby/moby/api v1.54.2 + github.com/moby/moby/client v0.4.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.43.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/fjl/jsonw v0.1.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/stun/v3 v3.1.2 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/DataDog/zstd v1.5.7 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523 // indirect + github.com/VictoriaMetrics/fastcache v1.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cockroachdb/errors v1.13.0 // indirect + github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect + github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect + github.com/cockroachdb/redact v1.1.8 // indirect + github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cosmos/ibc/link v0.0.0 + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dchest/siphash v1.2.3 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/emicklei/dot v1.11.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/ferranbt/fastssz v0.1.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getsentry/sentry-go v0.46.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grafana/pyroscope-go v1.2.7 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/hashicorp/go-bexpr v0.1.10 // indirect + github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db // indirect + github.com/holiman/bloomfilter/v2 v2.0.3 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/pointerstructure v1.2.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/urfave/cli/v2 v2.27.5 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +replace github.com/cosmos/ibc/link => ../../../link diff --git a/e2e/internal/harness/go.sum b/e2e/internal/harness/go.sum new file mode 100644 index 000000000..861c8b208 --- /dev/null +++ b/e2e/internal/harness/go.sum @@ -0,0 +1,444 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523 h1:pQUezn45oyv/8VcOQvPCg2851EIIusY0YKkJhAXsy2I= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260116142910-60249400e523/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +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.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ= +github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0= +github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A= +github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= +github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7KTaA= +github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw= +github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g= +github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b h1:wst+2QsydHKoplWYY7V07OqxKyCmL59hjFsDLQoqZ5I= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b/go.mod h1:ndpS//nq2Ghin4UFdp0Cg4lrHZwK1VS/QRmi6DL9H+g= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y= +github.com/emicklei/dot v1.11.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +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.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= +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= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= +github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0= +github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= +github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k= +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= +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/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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +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= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= +github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +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/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= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +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= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/e2e/internal/harness/ibclink/attestor.go b/e2e/internal/harness/ibclink/attestor.go new file mode 100644 index 000000000..3a7ff3f86 --- /dev/null +++ b/e2e/internal/harness/ibclink/attestor.go @@ -0,0 +1,431 @@ +// Package ibclink manages IBC Link processes as black boxes. +// +// Readiness means that the process loaded its private configuration and serves +// LatestAttestableHeight for the configured attestor. The current Link +// implementation returns a synthetic timestamp from that RPC; this package +// therefore does not claim that the process can produce or submit attestations. +package ibclink + +import ( + "bufio" + "context" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "connectrpc.com/connect" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "gopkg.in/yaml.v3" + + "github.com/cosmos/ibc/e2e/internal/harness/proc" + + attestorv2 "github.com/cosmos/ibc/link/api/v2/attestor" +) + +const ( + configFilename = "ibc.yml" + keyFilename = "attestor.json" + logFilename = "attestor.log" + + signerAlias = "managed-attestor-signer" + + attestorStartupTimeout = 30 * time.Second + probeRequestTimeout = 500 * time.Millisecond + attestorStopGrace = 12 * time.Second + startupLogTailBytes = 4096 +) + +// AttestorLaunch contains all process-local inputs needed to start one Link attestor. +// WorkDir must name a path that does not already exist; StartAttestor creates it with +// owner-only permissions and keeps the private key out of process arguments. +type AttestorLaunch struct { + BinaryPath string + WorkDir string + Name string + ChainID string + PrivateKeyHex string + + // RPCURL and RouterAddress locate the observed chain's ICS26 router the + // attestor queries for commitments. + RPCURL string + RouterAddress string +} + +type readinessResult struct { + address string + err error +} + +// AttestorProcess is a running IBC Link attestor whose public height endpoint has been +// successfully probed. It owns the subprocess group, but the caller owns the +// containing workspace and decides when its files are removed. +type AttestorProcess struct { + signerAddress common.Address + endpoint string + grpcAddress string + name string + client attestorv2.AttestationServiceClient + out *proc.LogWriter + readiness <-chan readinessResult + handle *proc.Handle +} + +// StartAttestor writes a private Link configuration and ECDSA key, starts `ibc +// attestor run`, and returns only after LatestAttestableHeight succeeds. +func StartAttestor(ctx context.Context, launch AttestorLaunch) (*AttestorProcess, error) { + key, err := validateAttestorLaunch(launch) + if err != nil { + return nil, err + } + binaryPath, err := filepath.Abs(launch.BinaryPath) + if err != nil { + return nil, fmt.Errorf("start IBC Link attestor: absolute binary path: %w", err) + } + + paths, err := prepareAttestorWorkspace(launch, key, "127.0.0.1:0") + if err != nil { + return nil, err + } + + out, err := proc.NewLogWriter(paths.Log) + if err != nil { + return nil, fmt.Errorf("start IBC Link attestor: create log: %w", err) + } + + cmd := exec.Command( + binaryPath, + "attestor", "run", + "--home", paths.Dir, + "--config", configFilename, + ) + cmd.Dir = paths.Dir + cmd.Stderr = out + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdout, err := cmd.StdoutPipe() + if err != nil { + out.Close() + return nil, fmt.Errorf("start IBC Link attestor: capture stdout: %w", err) + } + if err := cmd.Start(); err != nil { + _ = stdout.Close() + out.Close() + return nil, fmt.Errorf("start IBC Link attestor binary %q: %w", binaryPath, err) + } + readiness := make(chan readinessResult, 1) + stdoutDone := make(chan struct{}) + go func() { + defer close(stdoutDone) + observeReadiness(stdout, out, readiness) + }() + + p := &AttestorProcess{ + signerAddress: crypto.PubkeyToAddress(key.PublicKey), + name: launch.Name, + out: out, + readiness: readiness, + } + p.handle = proc.Reap(cmd, proc.Hooks{ + BeforeWait: func() { <-stdoutDone }, + AfterWait: out.Close, + }) + + if err := p.awaitReady(ctx); err != nil { + if cleanupErr := p.killAfterFailedStart(); cleanupErr != nil { + return p, errors.Join(err, fmt.Errorf("clean up failed IBC Link attestor start: %w", cleanupErr)) + } + return nil, err + } + return p, nil +} + +// SignerAddress is the Ethereum address derived from the private ECDSA key +// loaded by the child process. +func (p *AttestorProcess) SignerAddress() common.Address { return p.signerAddress } + +// GRPCAddress is the loopback host:port the process serves its attestation API +// on. A relayer references it as a remote attestor endpoint. +func (p *AttestorProcess) GRPCAddress() string { return p.grpcAddress } + +func (p *AttestorProcess) latestAttestableHeight(ctx context.Context) (uint64, error) { + response, err := p.client.LatestAttestableHeight( + ctx, + connect.NewRequest(&attestorv2.LatestAttestableHeightRequest{Attestor: p.name}), + ) + if err != nil { + return 0, fmt.Errorf("call latest attestable height at %s: %w", p.endpoint, err) + } + return response.Msg.Height, nil +} + +// Stop asks the whole subprocess group to exit and escalates to SIGKILL after +// a fixed grace period or when ctx expires. It is safe to call more than once. +func (p *AttestorProcess) Stop(ctx context.Context) error { + return p.handle.SignalAndWait(ctx, syscall.SIGTERM, attestorStopGrace) +} + +func (p *AttestorProcess) awaitReady(ctx context.Context) error { + startupCtx, cancel := context.WithTimeout(ctx, attestorStartupTimeout) + defer cancel() + address, err := p.awaitAddress(startupCtx) + if err != nil { + return err + } + p.grpcAddress = address + p.endpoint = "http://" + address + p.client = attestorv2.NewAttestationServiceClient( + &http.Client{Timeout: probeRequestTimeout}, + p.endpoint, + ) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + var lastProbeErr error + for { + probeCtx, probeCancel := context.WithTimeout(startupCtx, probeRequestTimeout) + _, probeErr := p.latestAttestableHeight(probeCtx) + probeCancel() + if probeErr == nil { + return nil + } + lastProbeErr = probeErr + + select { + case <-p.handle.Done(): + return p.readinessExitError() + case <-startupCtx.Done(): + return fmt.Errorf( + "IBC Link attestor was not ready at %s: %w (last probe: %s); logs: %s", + p.endpoint, + startupCtx.Err(), + lastProbeErr.Error(), + p.logTail(), + ) + case <-ticker.C: + } + } +} + +func (p *AttestorProcess) awaitAddress(ctx context.Context) (string, error) { + select { + case result := <-p.readiness: + if result.err != nil { + if errors.Is(result.err, io.EOF) { + return "", fmt.Errorf( + "IBC Link attestor exited before readiness: %w; logs: %s", + result.err, + p.logTail(), + ) + } + return "", fmt.Errorf( + "IBC Link attestor readiness failed: %w; logs: %s", + result.err, + p.logTail(), + ) + } + parsed, err := netip.ParseAddrPort(result.address) + if err != nil || !parsed.Addr().IsLoopback() || parsed.Port() == 0 { + return "", fmt.Errorf( + "IBC Link attestor announced invalid readiness address %q", + result.address, + ) + } + return result.address, nil + case <-p.handle.Done(): + return "", p.readinessExitError() + case <-ctx.Done(): + return "", fmt.Errorf( + "IBC Link attestor did not announce readiness: %w; logs: %s", + ctx.Err(), + p.logTail(), + ) + } +} + +func (p *AttestorProcess) readinessExitError() error { + processErr := p.handle.Err() + if processErr == nil { + processErr = errors.New("process exited successfully") + } + return fmt.Errorf( + "IBC Link attestor exited before readiness: %w; logs: %s", + processErr, + p.logTail(), + ) +} + +func (p *AttestorProcess) killAfterFailedStart() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return p.handle.SignalAndWait(ctx, syscall.SIGKILL, 0) +} + +func (p *AttestorProcess) logTail() string { + return strings.TrimSpace(string(p.out.TailSnapshot())) +} + +func validateAttestorLaunch(spec AttestorLaunch) (*ecdsa.PrivateKey, error) { + switch { + case spec.BinaryPath == "": + return nil, errors.New("start IBC Link attestor: binary path is required") + case spec.WorkDir == "": + return nil, errors.New("start IBC Link attestor: work dir is required") + case spec.Name == "": + return nil, errors.New("start IBC Link attestor: name is required") + case spec.ChainID == "": + return nil, errors.New("start IBC Link attestor: chain id is required") + case spec.PrivateKeyHex == "": + return nil, errors.New("start IBC Link attestor: private key is required") + case spec.RPCURL == "": + return nil, errors.New("start IBC Link attestor: rpc url is required") + case spec.RouterAddress == "": + return nil, errors.New("start IBC Link attestor: router address is required") + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(spec.PrivateKeyHex, "0x")) + if err != nil { + return nil, fmt.Errorf("start IBC Link attestor: parse private key: %w", err) + } + return key, nil +} + +func prepareAttestorWorkspace( + spec AttestorLaunch, + key *ecdsa.PrivateKey, + listenAddress string, +) (proc.WorkspacePaths, error) { + dir, err := filepath.Abs(spec.WorkDir) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link attestor: absolute work dir: %w", err) + } + keysDir, err := proc.CreatePrivateWorkspace(dir) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link attestor: %w", err) + } + keyPath := filepath.Join(keysDir, keyFilename) + if writeErr := proc.WriteECDSAKey(keyPath, key); writeErr != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link attestor: write private key file: %w", writeErr) + } + + config := fileConfig{ + Server: serverConfig{ListenAddress: listenAddress}, + DB: dbConfig{ + Type: "sqlite", + URL: filepath.Join(dir, "ibc.db"), + }, + Chains: []chainConfig{{ + ChainID: spec.ChainID, + EVM: evmChainConfig{ + RPC: spec.RPCURL, + ICS26Router: spec.RouterAddress, + }, + }}, + Attestor: attestorConfig{Attestations: []attestationConfig{{ + ChainID: spec.ChainID, + Name: spec.Name, + Signer: signerAlias, + EVM: evmAttestationConfig{ + RouterAddress: spec.RouterAddress, + // Explicit zero offset attests at the latest height; the harness + // lanes (instant/interval anvil, besu QBFT) all finalize instantly + // and may not serve the "finalized" block tag. + FinalityOffset: 0, + }, + }}}, + Signers: []signerConfig{{ + Alias: signerAlias, + Type: "local", + File: keyPath, + }}, + } + configData, err := yaml.Marshal(config) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link attestor: encode config: %w", err) + } + if err := proc.WritePrivateFile(filepath.Join(dir, configFilename), configData); err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link attestor: write config: %w", err) + } + return proc.WorkspacePaths{Dir: dir, Log: filepath.Join(dir, logFilename)}, nil +} + +func observeReadiness(stdout io.Reader, logs io.Writer, ready chan<- readinessResult) { + reader := bufio.NewReaderSize(stdout, startupLogTailBytes) + line, tooLong, err := reader.ReadLine() + _, _ = logs.Write(line) + if !tooLong { + _, _ = logs.Write([]byte{'\n'}) + } + switch { + case err != nil: + ready <- readinessResult{err: fmt.Errorf("read readiness line: %w", err)} + case tooLong: + ready <- readinessResult{err: fmt.Errorf("readiness line exceeds %d bytes", startupLogTailBytes)} + default: + var readiness attestorv2.ProcessReadiness + if err := json.Unmarshal(line, &readiness); err != nil { + ready <- readinessResult{err: fmt.Errorf("decode readiness: %w", err)} + } else if readiness.Event != attestorv2.ProcessReadinessEvent { + ready <- readinessResult{err: fmt.Errorf("unexpected readiness event %q", readiness.Event)} + } else { + ready <- readinessResult{address: readiness.HTTP} + } + } + _, _ = io.Copy(logs, reader) +} + +type fileConfig struct { + Server serverConfig `yaml:"server"` + DB dbConfig `yaml:"db"` + Chains []chainConfig `yaml:"chains"` + Attestor attestorConfig `yaml:"attestor"` + Signers []signerConfig `yaml:"signers"` +} + +type chainConfig struct { + ChainID string `yaml:"chainId"` + EVM evmChainConfig `yaml:"evm"` +} + +type evmChainConfig struct { + RPC string `yaml:"rpc"` + ICS26Router string `yaml:"ics26Router"` +} + +type serverConfig struct { + ListenAddress string `yaml:"listenAddr"` +} + +type dbConfig struct { + Type string `yaml:"type"` + URL string `yaml:"url"` +} + +type attestorConfig struct { + Attestations []attestationConfig `yaml:"attestations"` +} + +type attestationConfig struct { + ChainID string `yaml:"chainId"` + Name string `yaml:"name"` + Signer string `yaml:"signer"` + EVM evmAttestationConfig `yaml:"evm"` +} + +type evmAttestationConfig struct { + RouterAddress string `yaml:"routerAddress"` + FinalityOffset uint64 `yaml:"finalityOffset"` +} + +type signerConfig struct { + Alias string `yaml:"alias"` + Type string `yaml:"type"` + File string `yaml:"file"` +} diff --git a/e2e/internal/harness/ibclink/attestor_test.go b/e2e/internal/harness/ibclink/attestor_test.go new file mode 100644 index 000000000..f76fd5bdc --- /dev/null +++ b/e2e/internal/harness/ibclink/attestor_test.go @@ -0,0 +1,268 @@ +package ibclink + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + attestorv2 "github.com/cosmos/ibc/link/api/v2/attestor" +) + +const testPrivateKey = "0000000000000000000000000000000000000000000000000000000000000006" + +func TestStartProbesPublicEndpointAndStopsProcess(t *testing.T) { + binary := helperBinary(t) + workDir := filepath.Join(t.TempDir(), "attestor") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + process, err := StartAttestor(ctx, AttestorLaunch{ + BinaryPath: binary, + WorkDir: workDir, + Name: "attestor-a", + ChainID: "observed-chain-1", + PrivateKeyHex: testPrivateKey, + RPCURL: "http://127.0.0.1:8545", + RouterAddress: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0xE57bFE9F44b819898F47BF37E5AF72a0783e1141"), process.SignerAddress()) + height, err := process.latestAttestableHeight(ctx) + require.NoError(t, err) + require.Equal(t, uint64(4242), height) + + configInfo, err := os.Stat(filepath.Join(workDir, configFilename)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), configInfo.Mode().Perm()) + workDirInfo, err := os.Stat(workDir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), workDirInfo.Mode().Perm()) + keyPath := filepath.Join(workDir, "keys", keyFilename) + keyInfo, err := os.Stat(keyPath) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), keyInfo.Mode().Perm()) + keyData, err := os.ReadFile(keyPath) + require.NoError(t, err) + var stored struct { + Type string `json:"type"` + PrivateKey string `json:"privateKeyBase64"` + } + require.NoError(t, json.Unmarshal(keyData, &stored)) + require.Equal(t, "ecdsa", stored.Type) + decoded, err := base64.StdEncoding.DecodeString(stored.PrivateKey) + require.NoError(t, err) + require.Equal(t, testPrivateKey, fmt.Sprintf("%x", decoded)) + + logs, err := os.ReadFile(filepath.Join(workDir, logFilename)) + require.NoError(t, err) + require.Contains(t, string(logs), "helper listening") + childPIDData, err := os.ReadFile(filepath.Join(workDir, "helper-child.pid")) + require.NoError(t, err) + childPID, err := strconv.Atoi(strings.TrimSpace(string(childPIDData))) + require.NoError(t, err) + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + require.NoError(t, process.Stop(stopCtx)) + require.NoError(t, process.Stop(stopCtx), "Stop must be idempotent") + require.ErrorIs(t, syscall.Kill(childPID, 0), syscall.ESRCH, "Stop must signal descendants in the process group") + logs, err = os.ReadFile(filepath.Join(workDir, logFilename)) + require.NoError(t, err) + require.Contains(t, string(logs), "helper stopped") +} + +func TestStartReportsEarlyProcessExitWithLogs(t *testing.T) { + binary := filepath.Join(t.TempDir(), "ibc-fail") + require.NoError(t, os.WriteFile(binary, []byte("#!/bin/sh\necho deliberate-start-failure >&2\nexit 17\n"), 0o700)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := StartAttestor(ctx, AttestorLaunch{ + BinaryPath: binary, + WorkDir: filepath.Join(t.TempDir(), "attestor"), + Name: "attestor-a", + ChainID: "observed-chain-1", + PrivateKeyHex: testPrivateKey, + RPCURL: "http://127.0.0.1:8545", + RouterAddress: "0x0000000000000000000000000000000000000001", + }) + require.Error(t, err) + require.ErrorContains(t, err, "exited before readiness") + require.ErrorContains(t, err, "deliberate-start-failure") +} + +func TestStartRejectsInvalidInputsBeforeCreatingWorkspace(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "attestor") + _, err := StartAttestor(context.Background(), AttestorLaunch{ + BinaryPath: "/definitely/not/a/binary", + WorkDir: workDir, + Name: "attestor-a", + ChainID: "observed-chain-1", + PrivateKeyHex: "invalid", + RPCURL: "http://127.0.0.1:8545", + RouterAddress: "0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "parse private key") + _, statErr := os.Stat(workDir) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestStartRequiresFreshPrivateWorkspace(t *testing.T) { + workDir := t.TempDir() + _, err := StartAttestor(context.Background(), AttestorLaunch{ + BinaryPath: "/definitely/not/a/binary", + WorkDir: workDir, + Name: "attestor-a", + ChainID: "observed-chain-1", + PrivateKeyHex: testPrivateKey, + RPCURL: "http://127.0.0.1:8545", + RouterAddress: "0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "create private work dir") +} + +func TestIBCLinkAttestorHelperProcess(_ *testing.T) { + if os.Getenv("IBC_LINK_ATTESTOR_HELPER") != "1" { + return + } + if err := runAttestorHelper(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } +} + +func helperBinary(t *testing.T) string { + t.Helper() + testBinary, err := os.Executable() + require.NoError(t, err) + t.Setenv("IBC_LINK_ATTESTOR_TEST_BINARY", testBinary) + + path := filepath.Join(t.TempDir(), "ibc-helper") + script := `#!/bin/sh +IBC_LINK_ATTESTOR_HELPER=1 exec "$IBC_LINK_ATTESTOR_TEST_BINARY" -test.run '^TestIBCLinkAttestorHelperProcess$' -- "$@" +` + require.NoError(t, os.WriteFile(path, []byte(script), 0o700)) + return path +} + +func runAttestorHelper() error { + home, configName, err := helperConfigArgs(os.Args) + if err != nil { + return err + } + configData, err := os.ReadFile(filepath.Join(home, configName)) + if err != nil { + return fmt.Errorf("helper read config: %w", err) + } + var config fileConfig + if decodeErr := yaml.Unmarshal(configData, &config); decodeErr != nil { + return fmt.Errorf("helper decode config: %w", decodeErr) + } + if len(config.Attestor.Attestations) != 1 || config.Attestor.Attestations[0].Name != "attestor-a" { + return fmt.Errorf("helper received unexpected attestor config: %+v", config.Attestor) + } + if len(config.Signers) != 1 || config.Signers[0].Alias != signerAlias { + return fmt.Errorf("helper received unexpected signer config: %+v", config.Signers) + } + if _, readErr := os.ReadFile(config.Signers[0].File); readErr != nil { + return fmt.Errorf("helper read signer key: %w", readErr) + } + + listener, err := net.Listen("tcp", config.Server.ListenAddress) + if err != nil { + return fmt.Errorf("helper listen: %w", err) + } + if err := json.NewEncoder(os.Stdout).Encode(attestorv2.ProcessReadiness{ + Event: attestorv2.ProcessReadinessEvent, + HTTP: listener.Addr().String(), + }); err != nil { + return fmt.Errorf("helper announce readiness: %w", err) + } + mux := http.NewServeMux() + path, handler := attestorv2.NewAttestationServiceHandler(helperAttestationService{}) + mux.Handle(path, handler) + server := &http.Server{Handler: mux} + child := exec.Command("sleep", "60") + if err := child.Start(); err != nil { + return fmt.Errorf("helper start descendant: %w", err) + } + if err := os.WriteFile( + filepath.Join(home, "helper-child.pid"), + []byte(strconv.Itoa(child.Process.Pid)+"\n"), + 0o600, + ); err != nil { + return fmt.Errorf("helper write descendant pid: %w", err) + } + childDone := make(chan error, 1) + go func() { childDone <- child.Wait() }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + fmt.Fprintln(os.Stderr, "helper listening") + done := make(chan error, 1) + go func() { done <- server.Serve(listener) }() + select { + case <-signals: + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("helper shutdown: %w", err) + } + select { + case <-childDone: + case <-time.After(time.Second): + return fmt.Errorf("helper descendant did not receive process-group signal") + } + fmt.Fprintln(os.Stderr, "helper stopped") + return nil + case err := <-done: + return fmt.Errorf("helper server exited: %w", err) + } +} + +type helperAttestationService struct { + attestorv2.UnimplementedAttestationServiceHandler +} + +func (helperAttestationService) LatestAttestableHeight( + _ context.Context, + req *connect.Request[attestorv2.LatestAttestableHeightRequest], +) (*connect.Response[attestorv2.LatestAttestableHeightResponse], error) { + if req.Msg.Attestor != "attestor-a" { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown attestor %q", req.Msg.Attestor)) + } + return connect.NewResponse(&attestorv2.LatestAttestableHeightResponse{Height: 4242}), nil +} + +func helperConfigArgs(args []string) (string, string, error) { + var home, config string + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "--home": + home = args[i+1] + case "--config": + config = args[i+1] + } + } + if home == "" || config == "" { + return "", "", fmt.Errorf("helper requires --home and --config, got %q", args) + } + return home, config, nil +} diff --git a/e2e/internal/harness/ibclink/bin.go b/e2e/internal/harness/ibclink/bin.go new file mode 100644 index 000000000..2b6ca005e --- /dev/null +++ b/e2e/internal/harness/ibclink/bin.go @@ -0,0 +1,37 @@ +// Package ibclink resolves the IBC Link binary under test and manages the IBC +// Link attestor process as a black box (the relayer lives in the relayerproc +// package). The wire subpackage holds the CLI, config, and status API contracts +// asserted against the binary. +package ibclink + +import ( + "os" + "path/filepath" + "runtime" +) + +const realBinEnv = "IBC_BIN" + +// ResolvedBin returns the path to the IBC Link binary under test: the +// IBC_BIN override when set, otherwise the default link/bin/ibc built from this +// repository. +func ResolvedBin() string { + if v := os.Getenv(realBinEnv); v != "" { + return v + } + return defaultBinPath() +} + +// defaultBinPath resolves link/bin/ibc relative to this source file, not the +// process cwd, so tests locate the binary regardless of where `go test` runs. +func defaultBinPath() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + panic("ibclink: runtime.Caller(0) failed; cannot locate the link bin/ directory") + } + repoRoot := filepath.Dir(file) + for range 4 { + repoRoot = filepath.Dir(repoRoot) + } + return filepath.Join(repoRoot, "link", "bin", "ibc") +} diff --git a/e2e/internal/harness/ibclink/wire/cli.go b/e2e/internal/harness/ibclink/wire/cli.go new file mode 100644 index 000000000..540439ebb --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/cli.go @@ -0,0 +1,13 @@ +// Package wire defines the CLI, config, and status API contracts asserted by +// the black-box test adapter. +package wire + +// Exit codes follow BSD sysexits(3); tests assert on them directly. +const ( + ExitOK = 0 + ExitConfigInvalid = 64 + ExitRPCUnreachable = 65 + ExitTestAppDeployFailure = 66 + ExitNotReady = 69 + ExitInternal = 70 +) diff --git a/e2e/internal/harness/ibclink/wire/health.go b/e2e/internal/harness/ibclink/wire/health.go new file mode 100644 index 000000000..30628d8ba --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/health.go @@ -0,0 +1,3 @@ +package wire + +const HealthPath = "/health" diff --git a/e2e/internal/harness/ibclink/wire/migration.go b/e2e/internal/harness/ibclink/wire/migration.go new file mode 100644 index 000000000..a42e9e1a8 --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/migration.go @@ -0,0 +1,6 @@ +package wire + +type MigrationUpResult struct { + DB string `json:"db"` + Applied int `json:"applied"` +} diff --git a/e2e/internal/harness/ibclink/wire/readiness.go b/e2e/internal/harness/ibclink/wire/readiness.go new file mode 100644 index 000000000..d39e7c0f1 --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/readiness.go @@ -0,0 +1,38 @@ +package wire + +import "fmt" + +const ReadinessEvent = "ready" + +// This must be the first stdout line from `ibc relayer run`. +type Readiness struct { + Event string `json:"event"` + ConfigLoaded bool `json:"configLoaded"` + DBReady bool `json:"dbReady"` + ChainsConnected []string `json:"chainsConnected"` + RelayerSubscribed bool `json:"relayerSubscribed"` + Status ReadinessStatus `json:"status"` +} + +type ReadinessStatus struct { + HTTP string `json:"http"` +} + +func (r Readiness) Validate() error { + if r.Event != ReadinessEvent { + return fmt.Errorf("event = %q, want %q", r.Event, ReadinessEvent) + } + if !r.ConfigLoaded { + return fmt.Errorf("configLoaded is false") + } + if !r.DBReady { + return fmt.Errorf("dbReady is false") + } + if !r.RelayerSubscribed { + return fmt.Errorf("relayerSubscribed is false") + } + if r.Status.HTTP == "" { + return fmt.Errorf("status.http is empty") + } + return nil +} diff --git a/e2e/internal/harness/ibclink/wire/relay.go b/e2e/internal/harness/ibclink/wire/relay.go new file mode 100644 index 000000000..3cfd555d3 --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/relay.go @@ -0,0 +1,12 @@ +package wire + +const RelayPath = "/relay" + +type RelayRequest struct { + SourceChainID string `json:"sourceChainId"` + SourceTxHash string `json:"sourceTxHash"` +} + +type RelayResult struct { + PacketIDs []string `json:"packetIds"` +} diff --git a/e2e/internal/harness/ibclink/wire/status.go b/e2e/internal/harness/ibclink/wire/status.go new file mode 100644 index 000000000..d1e3ccf43 --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/status.go @@ -0,0 +1,63 @@ +package wire + +import "fmt" + +const StatusPath = "/status" + +// RelayerPacketID composes the packet identifier the real `ibc relayer run` +// daemon reports on its /status view: "-", where routeID is +// the relayer's configured source-client alias. This is the format asserted +// against a live daemon. +func RelayerPacketID(routeID string, sequence uint64) string { + return fmt.Sprintf("%s-%d", routeID, sequence) +} + +const ( + StatusQueryRoute = "route" + StatusQueryPacket = "packet" +) + +// The zero value asks for everything. +type StatusQuery struct { + RouteID string + PacketID string +} + +type PacketState string + +const ( + PacketPending PacketState = "pending" + PacketComplete PacketState = "complete" + PacketTimedOut PacketState = "timed_out" + PacketErrorAck PacketState = "error_ack" + // PacketFailed marks a packet whose relay failed permanently (the daemon + // emits it, e.g. on FAILED_INVARIANT). It is a terminal failure distinct from + // error_ack: the destination never accepted a delivery. Tests that wait for a + // packet to complete or to stay pending must treat it as terminal, not as an + // unrecognized (and therefore ignorable) state. + PacketFailed PacketState = "failed" +) + +type Status struct { + Packets []PacketStatus `json:"packets"` +} + +// Terminal states are persisted only after their on-chain effect lands. +type PacketStatus struct { + PacketID string `json:"packetId"` + RouteID string `json:"routeId"` + Sequence uint64 `json:"sequence"` + State PacketState `json:"state"` + SourceTxHash string `json:"sourceTxHash"` + EffectTxHash string `json:"effectTxHash,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (s *Status) Packet(packetID string) (PacketStatus, bool) { + for _, p := range s.Packets { + if p.PacketID == packetID { + return p, true + } + } + return PacketStatus{}, false +} diff --git a/e2e/internal/harness/ibclink/wire/validate.go b/e2e/internal/harness/ibclink/wire/validate.go new file mode 100644 index 000000000..6234fc60d --- /dev/null +++ b/e2e/internal/harness/ibclink/wire/validate.go @@ -0,0 +1,13 @@ +package wire + +type ValidationIssue struct { + Path string `json:"path"` + Message string `json:"message"` +} + +type ValidateResult struct { + StructurallyValid bool `json:"structurallyValid"` + ResolvedChains []string `json:"resolvedChains,omitempty"` + Warnings []ValidationIssue `json:"warnings"` + Errors []ValidationIssue `json:"errors,omitempty"` +} diff --git a/e2e/internal/harness/proc/handle.go b/e2e/internal/harness/proc/handle.go new file mode 100644 index 000000000..2a3c202c2 --- /dev/null +++ b/e2e/internal/harness/proc/handle.go @@ -0,0 +1,130 @@ +// Package proc supervises the lifecycle of local subprocess groups. +package proc + +import ( + "context" + "errors" + "fmt" + "os/exec" + "sync" + "syscall" + "time" +) + +// Stop/reap state machine for a Setpgid child: owns cmd.Wait() and guards +// group signals against PID recycling. SignalAndWait is safe to call repeatedly; +// each caller has its own wait context. +type Handle struct { + cmd *exec.Cmd + done chan struct{} + + mu sync.Mutex + waitErr error + // Avoid re-signaling a dead process group while cmd.Wait publishes reaped. + killSent bool + reaped bool +} + +// Hooks describe output lifecycle work around cmd.Wait. +type Hooks struct { + BeforeWait func() + AfterWait func() +} + +func Reap(cmd *exec.Cmd, hooks Hooks) *Handle { + h := &Handle{cmd: cmd, done: make(chan struct{})} + go func() { + if hooks.BeforeWait != nil { + hooks.BeforeWait() + } + err := cmd.Wait() + // Publish reaped under mu before closing done so SignalAndWait never signals a recycled pid. + h.mu.Lock() + h.waitErr = err + h.reaped = true + h.mu.Unlock() + if hooks.AfterWait != nil { + hooks.AfterWait() + } + close(h.done) + }() + return h +} + +func (h *Handle) Done() <-chan struct{} { return h.done } + +func (h *Handle) Err() error { + h.mu.Lock() + defer h.mu.Unlock() + return h.waitErr +} + +// Signals the group, escalating to SIGKILL after grace. It is safe to call +// repeatedly. Checks reaped under mu before signaling so a recycled pid is +// never killed. +func (h *Handle) SignalAndWait(ctx context.Context, sig syscall.Signal, grace time.Duration) error { + h.mu.Lock() + if h.reaped { + h.mu.Unlock() + return h.wait(ctx) + } + if h.killSent { + h.mu.Unlock() + return h.wait(ctx) + } + pgid := h.cmd.Process.Pid + signalErr := signalProcessGroup(pgid, sig) + if signalErr == nil && sig == syscall.SIGKILL { + h.killSent = true + } + h.mu.Unlock() + if signalErr != nil { + return signalErr + } + + if sig == syscall.SIGKILL || grace <= 0 { + return h.wait(ctx) + } + timer := time.NewTimer(grace) + defer timer.Stop() + select { + case <-h.done: + return nil + case <-timer.C: + if err := h.escalateKill(pgid); err != nil { + return err + } + return h.wait(ctx) + case <-ctx.Done(): + return errors.Join(ctx.Err(), h.escalateKill(pgid)) + } +} + +func (h *Handle) wait(ctx context.Context) error { + select { + case <-h.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *Handle) escalateKill(pgid int) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.reaped || h.killSent { + return nil + } + err := signalProcessGroup(pgid, syscall.SIGKILL) + if err == nil { + h.killSent = true + } + return err +} + +func signalProcessGroup(pgid int, signal syscall.Signal) error { + if err := syscall.Kill(-pgid, signal); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("signal process group %d with %s: %w", pgid, signal, err) + } + return nil +} diff --git a/e2e/internal/harness/proc/handle_test.go b/e2e/internal/harness/proc/handle_test.go new file mode 100644 index 000000000..23adcd622 --- /dev/null +++ b/e2e/internal/harness/proc/handle_test.go @@ -0,0 +1,108 @@ +package proc + +import ( + "bufio" + "context" + "errors" + "os/exec" + "syscall" + "testing" + "time" +) + +func TestSignalAndWaitCanceledThenReaps(t *testing.T) { + cmd := exec.Command("sh", "-c", "trap '' TERM; printf 'ready\\n'; while :; do sleep 60; done") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("create child stdout pipe: %v", err) + } + if startErr := cmd.Start(); startErr != nil { + t.Fatalf("start child: %v", startErr) + } + handle := Reap(cmd, Hooks{}) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = handle.SignalAndWait(ctx, syscall.SIGKILL, 0) + }) + + ready := make(chan error, 1) + go func() { + line, readErr := bufio.NewReader(stdout).ReadString('\n') + if readErr == nil && line != "ready\n" { + readErr = errors.New("child emitted an unexpected readiness line") + } + ready <- readErr + }() + select { + case readyErr := <-ready: + if readyErr != nil { + t.Fatalf("wait for child readiness: %v", readyErr) + } + case <-time.After(5 * time.Second): + t.Fatal("child did not become ready") + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + err = handle.SignalAndWait(canceled, syscall.SIGTERM, time.Minute) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled SignalAndWait error = %v, want context.Canceled", err) + } + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("canceled SignalAndWait took %v", elapsed) + } + + waitCtx, waitCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer waitCancel() + if err := handle.SignalAndWait(waitCtx, syscall.SIGTERM, 0); err != nil { + t.Fatalf("subsequent SignalAndWait: %v", err) + } + if handle.Err() == nil { + t.Fatal("reaped SIGKILL child has no wait error") + } +} + +func TestSignalAndWaitSIGKILLWaitsForReap(t *testing.T) { + cmd := exec.Command("sh", "-c", "while :; do sleep 60; done") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + + allowWait := make(chan struct{}) + handle := Reap(cmd, Hooks{BeforeWait: func() { <-allowWait }}) + t.Cleanup(func() { + closeIfOpen(allowWait) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = handle.SignalAndWait(ctx, syscall.SIGKILL, 0) + }) + + result := make(chan error, 1) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go func() { + result <- handle.SignalAndWait(ctx, syscall.SIGKILL, 20*time.Millisecond) + }() + + select { + case err := <-result: + t.Fatalf("SignalAndWait returned before reap: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(allowWait) + if err := <-result; err != nil { + t.Fatalf("SignalAndWait with SIGKILL and grace: %v", err) + } +} + +func closeIfOpen(ch chan struct{}) { + select { + case <-ch: + default: + close(ch) + } +} diff --git a/e2e/internal/harness/proc/logtail.go b/e2e/internal/harness/proc/logtail.go new file mode 100644 index 000000000..3be65cba5 --- /dev/null +++ b/e2e/internal/harness/proc/logtail.go @@ -0,0 +1,68 @@ +package proc + +import ( + "os" + "sync" +) + +// Bytes of trailing process output LogWriter keeps for a startup-failure tail. +const logTailBytes = 4096 + +// LogWriter tees a child's combined output to a file while retaining the last +// logTailBytes in memory, so a failed start can report a diagnostic tail. Its +// methods are safe for concurrent use; the child writes from its own goroutine +// while a caller reads the snapshot. +type LogWriter struct { + mu sync.Mutex + tail []byte + file *os.File +} + +// NewLogWriter opens path for the log file, failing if it already exists. +func NewLogWriter(path string) (*LogWriter, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + return &LogWriter{file: file}, nil +} + +func (w *LogWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.appendTail(data) + if w.file == nil { + return 0, os.ErrClosed + } + return w.file.Write(data) +} + +func (w *LogWriter) appendTail(data []byte) { + if len(data) >= logTailBytes { + w.tail = append(w.tail[:0], data[len(data)-logTailBytes:]...) + return + } + w.tail = append(w.tail, data...) + if overflow := len(w.tail) - logTailBytes; overflow > 0 { + copy(w.tail, w.tail[overflow:]) + w.tail = w.tail[:logTailBytes] + } +} + +// TailSnapshot returns a copy of the retained trailing output. +func (w *LogWriter) TailSnapshot() []byte { + w.mu.Lock() + defer w.mu.Unlock() + return append([]byte(nil), w.tail...) +} + +// Close closes the underlying file; subsequent writes return os.ErrClosed. It +// is safe to call from a reap hook while writes may still race in. +func (w *LogWriter) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.file != nil { + _ = w.file.Close() + w.file = nil + } +} diff --git a/e2e/internal/harness/proc/workspace.go b/e2e/internal/harness/proc/workspace.go new file mode 100644 index 000000000..507573953 --- /dev/null +++ b/e2e/internal/harness/proc/workspace.go @@ -0,0 +1,69 @@ +package proc + +import ( + "crypto/ecdsa" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/ethereum/go-ethereum/crypto" +) + +// WorkspacePaths locates the two files a process supervisor needs after it lays +// out a private workspace: the home directory passed to the child and the log +// file its output is teed to. +type WorkspacePaths struct { + Dir string + Log string +} + +// keyFile is the on-disk signer key layout Link binaries load. PrivateKey holds +// the base64-encoded raw ECDSA key bytes. +type keyFile struct { + Type string `json:"type"` + PrivateKey string `json:"privateKeyBase64"` +} + +// CreatePrivateWorkspace creates dir and a nested keys/ directory, both +// owner-only, failing if dir already exists. It returns the keys directory so +// callers only choose key filenames. +func CreatePrivateWorkspace(dir string) (keysDir string, err error) { + if mkdirErr := os.Mkdir(dir, 0o700); mkdirErr != nil { + return "", fmt.Errorf("create private work dir %q: %w", dir, mkdirErr) + } + keysDir = filepath.Join(dir, "keys") + if mkdirErr := os.Mkdir(keysDir, 0o700); mkdirErr != nil { + return "", fmt.Errorf("create private keys dir: %w", mkdirErr) + } + return keysDir, nil +} + +// WriteECDSAKey encodes key in the layout Link binaries load and writes it to an +// owner-only file, failing if the path already exists. +func WriteECDSAKey(path string, key *ecdsa.PrivateKey) error { + data, err := json.Marshal(keyFile{ + Type: "ecdsa", + PrivateKey: base64.StdEncoding.EncodeToString(crypto.FromECDSA(key)), + }) + if err != nil { + return fmt.Errorf("encode ecdsa key file: %w", err) + } + return WritePrivateFile(path, data) +} + +// WritePrivateFile creates path with owner-only permissions, failing if it +// already exists, and writes data. It keeps secrets (keys, config) out of any +// pre-existing file and off group/other-readable modes. +func WritePrivateFile(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/e2e/internal/harness/relayerproc/config.go b/e2e/internal/harness/relayerproc/config.go new file mode 100644 index 000000000..59cf044c8 --- /dev/null +++ b/e2e/internal/harness/relayerproc/config.go @@ -0,0 +1,405 @@ +package relayerproc + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// The server binds an ephemeral loopback port; the real port is reported back +// on the readiness line as status.http, so it is never hard-coded here. +const serverListenAddr = "127.0.0.1:0" + +const ( + chainTypeAttestation = "attestation" + attestorTypeRemote = "remote" + attestorTypeLocal = "local" + signerTypeLocal = "local" + dbTypeSQLite = "sqlite" + dbTypePostgres = "postgres" + + // defaultLookback is the auto-relay lookback used when a Route leaves it + // unset. + defaultLookback uint64 = 100 +) + +// Config is the wall-respecting, resolved description of one relayer's +// configuration. It is deliberately independent of link/internal so the harness +// never imports the product's config package; renderConfig emits the YAML schema +// asserted by `ibc relayer run`. +type Config struct { + // Chains lists every EVM Chain the relayer must reach. Both Chains of the + // served Connection must appear exactly once. + Chains []ChainParams + // Clients declares both directions of the served Connection as source + // clients with their attestor sets. + Clients []ClientParams + // Routes declares which source clients to relay and how. + Routes []RouteParams + // Attestations, when non-empty, folds one or more in-process attestors into + // this relayer process (dual mode): the `ibc relayer run` binary serves the + // relayer, attestor, and signer from this single config. Each entry renders a + // top-level `attestor.attestations` block; a client's AttestorRef with the + // same Name and Local set true then routes to the in-process attestor with no + // network hop. Empty selects the pure-relayer path with remote attestors. + Attestations []AttestationParams + // PostgresURL overrides the relayer's persistence backend. Empty selects the + // default embedded sqlite database written under the workspace; a non-empty DSN + // runs the relayer against that external Postgres. + PostgresURL string +} + +// ChainParams is one EVM Chain the relayer signs and reads on. +type ChainParams struct { + ChainID string + RPC string + ICS26Router string +} + +// ClientParams is one directed source client entry. Its AttestorSet attests the +// counterparty Chain (the Chain this client tracks). +type ClientParams struct { + Alias string + ClientID string + ChainID string + CounterpartyChainID string + CounterpartyClientID string + Threshold uint8 + FinalityOffset uint64 + Attestors []AttestorRef +} + +// AttestorRef is one attestor contributing to a client's set. A ref is local +// (dual mode) when its Name matches an entry in Config.Attestations: it then +// routes to that in-process attestation and carries no GRPC endpoint. Otherwise +// it is remote and reaches a separate attestor process over GRPC. +type AttestorRef struct { + Name string + GRPC string + // EVMAddress is the attestor's on-chain signer identity (hex, 0x-prefixed). + // Required for remote refs (the relayer config enforces it and the proof + // generator checks signer identity against it); derived from the folded + // attestation's key file for local refs, so it may be empty there. + EVMAddress string +} + +// AttestationParams is one attestor folded into the relayer process (dual mode). +// It mirrors the top-level `attestor.attestations` block the `ibc attestor run` +// path uses: the attestor observes ChainID's ICS26 router at RouterAddress and +// signs with the private key written for its derived signer alias +// (attestationSignerAlias). +type AttestationParams struct { + ChainID string + Name string + PrivateKeyHex string + RouterAddress string + FinalityOffset uint64 +} + +// attestationSignerAlias is the signer alias a folded attestation signs with. It +// is derived from the attestation name so the relayer config and the key files +// written for it agree without threading a redundant field. +func attestationSignerAlias(name string) string { + return "attestor-" + name +} + +// RouteParams selects a source client to relay. AutoRelay nil means automatic +// relaying; a non-nil false requires manual relay via POST /relay. +type RouteParams struct { + SourceClient string + AutoRelay *bool + Lookback uint64 +} + +// fileConfig and its children mirror the `ibc relayer run` YAML schema. +type fileConfig struct { + Server serverConfig `yaml:"server"` + DB dbConfig `yaml:"db"` + Chains []chainConfig `yaml:"chains"` + Relayer relayerConfig `yaml:"relayer"` + Attestor *attestorConfig `yaml:"attestor,omitempty"` + Signers []signerConfig `yaml:"signers"` +} + +// attestorConfig is the top-level dual-mode attestor block, rendered only when +// the relayer folds in-process attestors. Omitted entirely for a pure relayer. +type attestorConfig struct { + Attestations []attestationConfig `yaml:"attestations"` +} + +type attestationConfig struct { + ChainID string `yaml:"chainId"` + Name string `yaml:"name"` + Signer string `yaml:"signer"` + EVM evmAttestationConfig `yaml:"evm"` +} + +type evmAttestationConfig struct { + RouterAddress string `yaml:"routerAddress"` + FinalityOffset uint64 `yaml:"finalityOffset"` +} + +type serverConfig struct { + ListenAddr string `yaml:"listenAddr"` +} + +type dbConfig struct { + Type string `yaml:"type"` + URL string `yaml:"url"` +} + +type chainConfig struct { + ChainID string `yaml:"chainId"` + EVM evmConfig `yaml:"evm"` +} + +type evmConfig struct { + RPC string `yaml:"rpc"` + ICS26Router string `yaml:"ics26Router"` +} + +type relayerConfig struct { + Signer string `yaml:"signer"` + Clients []clientConfig `yaml:"clients"` + RoutesToRelay []routeConfig `yaml:"routesToRelay"` +} + +type clientConfig struct { + Alias string `yaml:"alias"` + ClientID string `yaml:"clientId"` + ChainID string `yaml:"chainId"` + CounterpartyChainID string `yaml:"counterpartyChainId"` + CounterpartyClientID string `yaml:"counterpartyClientId"` + Type string `yaml:"type"` + AttestorSet attestorSetConfig `yaml:"attestorSet"` +} + +type attestorSetConfig struct { + CounterpartyChainFinalityOffset uint64 `yaml:"counterpartyChainFinalityOffset"` + Threshold uint8 `yaml:"threshold"` + Attestors []attestorRefConfig `yaml:"attestors"` +} + +type attestorRefConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + GRPC string `yaml:"grpc,omitempty"` + EVMAddress string `yaml:"evmAddress,omitempty"` +} + +type routeConfig struct { + SourceClient string `yaml:"sourceClient"` + AutoRelay *autoRelayConfig `yaml:"autoRelay,omitempty"` +} + +type autoRelayConfig struct { + Enabled bool `yaml:"enabled"` + Lookback uint64 `yaml:"lookback,omitempty"` +} + +type signerConfig struct { + Alias string `yaml:"alias"` + Type string `yaml:"type"` + File string `yaml:"file"` +} + +// attestorGRPCURL normalizes a resolved attestor endpoint to the URL form the +// `ibc relayer run` connect client requires. Its gRPC dialer rejects a bare +// host:port ("missing scheme: use http:// or https://"), so a loopback endpoint +// without a scheme is served as insecure h2c over http. +func attestorGRPCURL(endpoint string) string { + if strings.Contains(endpoint, "://") { + return endpoint + } + return "http://" + endpoint +} + +// renderConfig validates the resolved Config and encodes the `ibc relayer run` +// YAML that references the private signer keyfile and sqlite database. When +// config.Attestations is non-empty the relayer runs in dual mode; attestorKeyPaths +// maps each attestation's derived signer alias (attestationSignerAlias) to the +// private keyfile prepareWorkspace wrote for it. +func renderConfig( + config Config, + signerAlias, keyPath, dbPath string, + attestorKeyPaths map[string]string, +) ([]byte, error) { + if len(config.Chains) == 0 { + return nil, fmt.Errorf("relayer config: at least one chain is required") + } + if len(config.Clients) == 0 { + return nil, fmt.Errorf("relayer config: at least one client is required") + } + if len(config.Routes) == 0 { + return nil, fmt.Errorf("relayer config: at least one route is required") + } + + chains := make([]chainConfig, 0, len(config.Chains)) + chainIDs := make(map[string]struct{}, len(config.Chains)) + for _, chain := range config.Chains { + if chain.ChainID == "" || chain.RPC == "" || chain.ICS26Router == "" { + return nil, fmt.Errorf("relayer config: chain %q requires chainId, rpc, and ics26Router", chain.ChainID) + } + chains = append(chains, chainConfig{ + ChainID: chain.ChainID, + EVM: evmConfig{RPC: chain.RPC, ICS26Router: chain.ICS26Router}, + }) + chainIDs[chain.ChainID] = struct{}{} + } + + // attestationNames indexes the folded in-process attestors so a client's + // local AttestorRef can be checked against a declared attestation. + attestationNames := make(map[string]struct{}, len(config.Attestations)) + for _, attestation := range config.Attestations { + attestationNames[attestation.Name] = struct{}{} + } + + clients := make([]clientConfig, 0, len(config.Clients)) + for _, client := range config.Clients { + if len(client.Attestors) == 0 { + return nil, fmt.Errorf("relayer config: client %q requires at least one attestor", client.Alias) + } + if client.Threshold == 0 || int(client.Threshold) > len(client.Attestors) { + return nil, fmt.Errorf( + "relayer config: client %q threshold %d is out of range for %d attestors", + client.Alias, client.Threshold, len(client.Attestors), + ) + } + attestors := make([]attestorRefConfig, 0, len(client.Attestors)) + for _, attestor := range client.Attestors { + if attestor.Name == "" { + return nil, fmt.Errorf("relayer config: client %q attestor requires a name", client.Alias) + } + // A ref is local exactly when it names a folded attestation; otherwise it + // reaches a separate attestor process over gRPC. + if _, ok := attestationNames[attestor.Name]; ok { + attestors = append(attestors, attestorRefConfig{Name: attestor.Name, Type: attestorTypeLocal}) + continue + } + if attestor.GRPC == "" { + return nil, fmt.Errorf( + "relayer config: client %q remote attestor %q requires grpc", + client.Alias, + attestor.Name, + ) + } + if attestor.EVMAddress == "" { + return nil, fmt.Errorf( + "relayer config: client %q remote attestor %q requires evmAddress", + client.Alias, + attestor.Name, + ) + } + attestors = append(attestors, attestorRefConfig{ + Name: attestor.Name, + Type: attestorTypeRemote, + GRPC: attestorGRPCURL(attestor.GRPC), + EVMAddress: attestor.EVMAddress, + }) + } + clients = append(clients, clientConfig{ + Alias: client.Alias, + ClientID: client.ClientID, + ChainID: client.ChainID, + CounterpartyChainID: client.CounterpartyChainID, + CounterpartyClientID: client.CounterpartyClientID, + Type: chainTypeAttestation, + AttestorSet: attestorSetConfig{ + CounterpartyChainFinalityOffset: client.FinalityOffset, + Threshold: client.Threshold, + Attestors: attestors, + }, + }) + } + + routes := make([]routeConfig, 0, len(config.Routes)) + for _, route := range config.Routes { + if route.SourceClient == "" { + return nil, fmt.Errorf("relayer config: route requires a source client") + } + enabled := route.AutoRelay == nil || *route.AutoRelay + lookback := route.Lookback + if lookback == 0 { + lookback = defaultLookback + } + routes = append(routes, routeConfig{ + SourceClient: route.SourceClient, + AutoRelay: &autoRelayConfig{Enabled: enabled, Lookback: lookback}, + }) + } + + signers := []signerConfig{{Alias: signerAlias, Type: signerTypeLocal, File: keyPath}} + var attestorBlock *attestorConfig + if len(config.Attestations) > 0 { + attestations := make([]attestationConfig, 0, len(config.Attestations)) + signerFiles := make(map[string]struct{}, len(config.Attestations)) + for _, attestation := range config.Attestations { + switch { + case attestation.ChainID == "": + return nil, fmt.Errorf("relayer config: attestation %q requires a chainId", attestation.Name) + case attestation.Name == "": + return nil, fmt.Errorf("relayer config: attestation requires a name") + case attestation.RouterAddress == "": + return nil, fmt.Errorf("relayer config: attestation %q requires a routerAddress", attestation.Name) + } + if _, ok := chainIDs[attestation.ChainID]; !ok { + return nil, fmt.Errorf( + "relayer config: attestation %q references chain %q that is not configured", + attestation.Name, attestation.ChainID, + ) + } + alias := attestationSignerAlias(attestation.Name) + keyFile, ok := attestorKeyPaths[alias] + if !ok || keyFile == "" { + return nil, fmt.Errorf( + "relayer config: attestation %q signer %q has no key file", + attestation.Name, + alias, + ) + } + attestations = append(attestations, attestationConfig{ + ChainID: attestation.ChainID, + Name: attestation.Name, + Signer: alias, + EVM: evmAttestationConfig{ + RouterAddress: attestation.RouterAddress, + FinalityOffset: attestation.FinalityOffset, + }, + }) + // One signer entry per unique alias, distinct from the relay signer. + if _, done := signerFiles[alias]; !done { + signerFiles[alias] = struct{}{} + signers = append(signers, signerConfig{ + Alias: alias, Type: signerTypeLocal, File: keyFile, + }) + } + } + attestorBlock = &attestorConfig{Attestations: attestations} + } + + // Empty PostgresURL keeps the embedded sqlite database at the workspace path; a + // non-empty DSN runs the relayer against that external Postgres. + db := dbConfig{Type: dbTypeSQLite, URL: dbPath} + if config.PostgresURL != "" { + db = dbConfig{Type: dbTypePostgres, URL: config.PostgresURL} + } + + file := fileConfig{ + Server: serverConfig{ListenAddr: serverListenAddr}, + DB: db, + Chains: chains, + Relayer: relayerConfig{ + Signer: signerAlias, + Clients: clients, + RoutesToRelay: routes, + }, + Attestor: attestorBlock, + Signers: signers, + } + out, err := yaml.Marshal(file) + if err != nil { + return nil, fmt.Errorf("relayer config: encode yaml: %w", err) + } + return out, nil +} diff --git a/e2e/internal/harness/relayerproc/config_test.go b/e2e/internal/harness/relayerproc/config_test.go new file mode 100644 index 000000000..41b4ce4e7 --- /dev/null +++ b/e2e/internal/harness/relayerproc/config_test.go @@ -0,0 +1,295 @@ +package relayerproc + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func boolPtr(v bool) *bool { return &v } + +// TestRenderConfigMatchesRealBinarySchema locks the emitted YAML against the +// `ibc relayer run` schema, including attestorSet orientation for both +// directions of a Connection. +func TestRenderConfigMatchesRealBinarySchema(t *testing.T) { + config := Config{ + Chains: []ChainParams{ + {ChainID: "31337", RPC: "http://127.0.0.1:8545", ICS26Router: "0xaaaa000000000000000000000000000000000001"}, + {ChainID: "31338", RPC: "http://127.0.0.1:8546", ICS26Router: "0xbbbb000000000000000000000000000000000002"}, + }, + Clients: []ClientParams{ + { + Alias: "client-a", + ClientID: "client-a-onchain", + ChainID: "31337", + CounterpartyChainID: "31338", + CounterpartyClientID: "client-b-onchain", + Threshold: 2, + FinalityOffset: 0, + Attestors: []AttestorRef{ + { + Name: "attestor-alice", GRPC: "127.0.0.1:1111", + EVMAddress: "0x1111000000000000000000000000000000000001", + }, + { + Name: "attestor-bob", GRPC: "127.0.0.1:2222", + EVMAddress: "0x2222000000000000000000000000000000000002", + }, + }, + }, + { + Alias: "client-b", + ClientID: "client-b-onchain", + ChainID: "31338", + CounterpartyChainID: "31337", + CounterpartyClientID: "client-a-onchain", + Threshold: 1, + FinalityOffset: 0, + Attestors: []AttestorRef{ + { + Name: "attestor-carol", GRPC: "127.0.0.1:3333", + EVMAddress: "0x3333000000000000000000000000000000000003", + }, + }, + }, + }, + Routes: []RouteParams{ + {SourceClient: "client-a"}, + {SourceClient: "client-b", AutoRelay: boolPtr(false), Lookback: 50}, + }, + } + + out, err := renderConfig(config, "managed-relayer-signer", "/work/keys/relayer.json", "/work/ibc.db", nil) + require.NoError(t, err) + + const golden = `server: + listenAddr: 127.0.0.1:0 +db: + type: sqlite + url: /work/ibc.db +chains: + - chainId: "31337" + evm: + rpc: http://127.0.0.1:8545 + ics26Router: 0xaaaa000000000000000000000000000000000001 + - chainId: "31338" + evm: + rpc: http://127.0.0.1:8546 + ics26Router: 0xbbbb000000000000000000000000000000000002 +relayer: + signer: managed-relayer-signer + clients: + - alias: client-a + clientId: client-a-onchain + chainId: "31337" + counterpartyChainId: "31338" + counterpartyClientId: client-b-onchain + type: attestation + attestorSet: + counterpartyChainFinalityOffset: 0 + threshold: 2 + attestors: + - name: attestor-alice + type: remote + grpc: http://127.0.0.1:1111 + evmAddress: 0x1111000000000000000000000000000000000001 + - name: attestor-bob + type: remote + grpc: http://127.0.0.1:2222 + evmAddress: 0x2222000000000000000000000000000000000002 + - alias: client-b + clientId: client-b-onchain + chainId: "31338" + counterpartyChainId: "31337" + counterpartyClientId: client-a-onchain + type: attestation + attestorSet: + counterpartyChainFinalityOffset: 0 + threshold: 1 + attestors: + - name: attestor-carol + type: remote + grpc: http://127.0.0.1:3333 + evmAddress: 0x3333000000000000000000000000000000000003 + routesToRelay: + - sourceClient: client-a + autoRelay: + enabled: true + lookback: 100 + - sourceClient: client-b + autoRelay: + enabled: false + lookback: 50 +signers: + - alias: managed-relayer-signer + type: local + file: /work/keys/relayer.json +` + require.Equal(t, golden, string(out)) +} + +// TestRenderConfigDualModeProjectsFoldedAttestors asserts only the subtree that +// dual mode adds: local (no-gRPC) client attestor refs, a top-level +// `attestor.attestations` block signed by derived aliases, and one added signer +// entry per folded attestor. The full document schema is already locked by +// TestRenderConfigMatchesRealBinarySchema, so this test does not re-duplicate it. +func TestRenderConfigDualModeProjectsFoldedAttestors(t *testing.T) { + config := Config{ + Chains: []ChainParams{ + {ChainID: "31337", RPC: "http://127.0.0.1:8545", ICS26Router: "0xaaaa000000000000000000000000000000000001"}, + {ChainID: "31338", RPC: "http://127.0.0.1:8546", ICS26Router: "0xbbbb000000000000000000000000000000000002"}, + }, + Clients: []ClientParams{ + { + Alias: "client-a", ClientID: "client-a-onchain", ChainID: "31337", CounterpartyChainID: "31338", + CounterpartyClientID: "client-b-onchain", Threshold: 1, + Attestors: []AttestorRef{{Name: "attestor-a"}}, + }, + { + Alias: "client-b", ClientID: "client-b-onchain", ChainID: "31338", CounterpartyChainID: "31337", + CounterpartyClientID: "client-a-onchain", Threshold: 1, + Attestors: []AttestorRef{{Name: "attestor-b"}}, + }, + }, + Routes: []RouteParams{{SourceClient: "client-a"}, {SourceClient: "client-b"}}, + Attestations: []AttestationParams{ + { + ChainID: "31338", Name: "attestor-a", + PrivateKeyHex: "0000000000000000000000000000000000000000000000000000000000000001", + RouterAddress: "0xbbbb000000000000000000000000000000000002", + }, + { + ChainID: "31337", Name: "attestor-b", + PrivateKeyHex: "0000000000000000000000000000000000000000000000000000000000000002", + RouterAddress: "0xaaaa000000000000000000000000000000000001", + }, + }, + } + + out, err := renderConfig(config, "managed-relayer-signer", "/work/keys/relayer.json", "/work/ibc.db", + map[string]string{ + "attestor-attestor-a": "/work/keys/attestor-0.json", + "attestor-attestor-b": "/work/keys/attestor-1.json", + }) + require.NoError(t, err) + + var rendered fileConfig + require.NoError(t, yaml.Unmarshal(out, &rendered)) + + // Each client references its attestor as a local (in-process) ref with no gRPC. + for _, client := range rendered.Relayer.Clients { + require.Len(t, client.AttestorSet.Attestors, 1) + ref := client.AttestorSet.Attestors[0] + require.Equal(t, attestorTypeLocal, ref.Type) + require.Empty(t, ref.GRPC) + } + + // The folded attestors render a top-level attestations block signed by the + // aliases derived from their names. + require.NotNil(t, rendered.Attestor) + require.Equal(t, []attestationConfig{ + { + ChainID: "31338", Name: "attestor-a", Signer: "attestor-attestor-a", + EVM: evmAttestationConfig{RouterAddress: "0xbbbb000000000000000000000000000000000002"}, + }, + { + ChainID: "31337", Name: "attestor-b", Signer: "attestor-attestor-b", + EVM: evmAttestationConfig{RouterAddress: "0xaaaa000000000000000000000000000000000001"}, + }, + }, rendered.Attestor.Attestations) + + // One signer entry per folded attestor is added after the relay signer. + require.Equal(t, []signerConfig{ + {Alias: "managed-relayer-signer", Type: "local", File: "/work/keys/relayer.json"}, + {Alias: "attestor-attestor-a", Type: "local", File: "/work/keys/attestor-0.json"}, + {Alias: "attestor-attestor-b", Type: "local", File: "/work/keys/attestor-1.json"}, + }, rendered.Signers) +} + +func TestRenderConfigRejectsInvalidGraph(t *testing.T) { + base := func() Config { + return Config{ + Chains: []ChainParams{{ChainID: "1", RPC: "http://rpc", ICS26Router: "0x01"}}, + Clients: []ClientParams{{ + Alias: "c", ClientID: "c-0", ChainID: "1", CounterpartyChainID: "2", + CounterpartyClientID: "d-0", Threshold: 1, + Attestors: []AttestorRef{{Name: "a", GRPC: "127.0.0.1:1", EVMAddress: "0x0a"}}, + }}, + Routes: []RouteParams{{SourceClient: "c"}}, + } + } + + tests := []struct { + name string + mutate func(*Config) + want string + }{ + {"no chains", func(c *Config) { c.Chains = nil }, "at least one chain"}, + {"no clients", func(c *Config) { c.Clients = nil }, "at least one client"}, + {"no routes", func(c *Config) { c.Routes = nil }, "at least one route"}, + { + "chain missing router", + func(c *Config) { c.Chains[0].ICS26Router = "" }, + "requires chainId, rpc, and ics26Router", + }, + {"client without attestor", func(c *Config) { c.Clients[0].Attestors = nil }, "requires at least one attestor"}, + {"threshold too high", func(c *Config) { c.Clients[0].Threshold = 2 }, "threshold 2 is out of range"}, + {"threshold zero", func(c *Config) { c.Clients[0].Threshold = 0 }, "threshold 0 is out of range"}, + { + "attestor missing grpc", + func(c *Config) { c.Clients[0].Attestors[0].GRPC = "" }, + `remote attestor "a" requires grpc`, + }, + { + "remote attestor missing evmAddress", + func(c *Config) { c.Clients[0].Attestors[0].EVMAddress = "" }, + `remote attestor "a" requires evmAddress`, + }, + {"route missing source", func(c *Config) { c.Routes[0].SourceClient = "" }, "requires a source client"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + config := base() + tc.mutate(&config) + _, err := renderConfig(config, "signer", "/keys/relayer.json", "/ibc.db", nil) + require.ErrorContains(t, err, tc.want) + }) + } +} + +// TestRenderConfigDatabaseOverride proves the db block defaults to the workspace +// sqlite path and can be overridden to postgres with an explicit DSN. +func TestRenderConfigDatabaseOverride(t *testing.T) { + base := func() Config { + return Config{ + Chains: []ChainParams{{ChainID: "1", RPC: "http://rpc", ICS26Router: "0x01"}}, + Clients: []ClientParams{{ + Alias: "c", ClientID: "c-0", ChainID: "1", CounterpartyChainID: "2", + CounterpartyClientID: "d-0", Threshold: 1, + Attestors: []AttestorRef{{Name: "a", GRPC: "127.0.0.1:1", EVMAddress: "0x0a"}}, + }}, + Routes: []RouteParams{{SourceClient: "c"}}, + } + } + + t.Run("default sqlite uses the workspace path", func(t *testing.T) { + out, err := renderConfig(base(), "signer", "/keys/relayer.json", "/work/ibc.db", nil) + require.NoError(t, err) + var rendered fileConfig + require.NoError(t, yaml.Unmarshal(out, &rendered)) + require.Equal(t, dbConfig{Type: "sqlite", URL: "/work/ibc.db"}, rendered.DB) + }) + + t.Run("postgres override renders the dsn", func(t *testing.T) { + config := base() + config.PostgresURL = "postgres://u:p@127.0.0.1:5432/relayer?sslmode=disable" + out, err := renderConfig(config, "signer", "/keys/relayer.json", "/work/ibc.db", nil) + require.NoError(t, err) + var rendered fileConfig + require.NoError(t, yaml.Unmarshal(out, &rendered)) + require.Equal(t, dbConfig{ + Type: "postgres", URL: "postgres://u:p@127.0.0.1:5432/relayer?sslmode=disable", + }, rendered.DB) + }) +} diff --git a/e2e/internal/harness/relayerproc/relayer.go b/e2e/internal/harness/relayerproc/relayer.go new file mode 100644 index 000000000..061c2ac30 --- /dev/null +++ b/e2e/internal/harness/relayerproc/relayer.go @@ -0,0 +1,478 @@ +// Package relayerproc manages the current IBC Link relayer process as a black +// box. It writes a private `ibc relayer run` configuration and funded signer +// keyfile, launches the real binary, and returns only after the process reports +// readiness on stdout and its /health endpoint answers. +// +// It mirrors the ibclink attestor runner: the process owns its subprocess group, while the +// caller owns the containing workspace and decides when its files are removed. +package relayerproc + +import ( + "bufio" + "bytes" + "context" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" + "github.com/cosmos/ibc/e2e/internal/harness/proc" +) + +const ( + configFilename = "ibc.yml" + keyFilename = "relayer.json" + dbFilename = "ibc.db" + logFilename = "relayer.log" + + signerAlias = "managed-relayer-signer" + + defaultStartupTimeout = 60 * time.Second + // SIGKILL escalation margin after SIGTERM so packets draining through the + // HTTP server get a graceful window. + stopGrace = 12 * time.Second + // Backstop so a status endpoint that accepts but never answers cannot hang + // an unbounded caller context forever. + statusHTTPTimeout = 30 * time.Second + killTimeout = 5 * time.Second + errorBodyLimit = 512 +) + +// Spec contains all process-local inputs needed to start one Link relayer. +// WorkDir must name a path that does not already exist; Start creates it with +// owner-only permissions and keeps the signer key out of process arguments. +type Spec struct { + BinaryPath string + WorkDir string + RelaySignerKeyHex string + Config Config + // StartupTimeout bounds the readiness wait. Zero selects a default. + StartupTimeout time.Duration +} + +// Relayer is a running `ibc relayer run` process whose readiness line has been +// parsed and whose /health endpoint has answered. It owns the subprocess group. +type Relayer struct { + readiness wire.Readiness + httpAddr string + signer common.Address + http *http.Client + out *proc.LogWriter + handle *proc.Handle + + // Retained launch inputs so Restart can relaunch `ibc relayer run` against the + // same on-disk workspace (config, signer key, and database) after a Stop/Kill, + // preserving persisted relay state. + binaryPath string + dir string + config Config + startupTimeout time.Duration + // restarts counts Restart attempts, incremented per attempt (not per + // successful launch) so a failed relaunch still advances the per-launch log + // filename. Otherwise a retry would reuse the O_EXCL-created log path left + // behind by the failed attempt and fail deterministically on every retry. + restarts int +} + +type readyResult struct { + readiness wire.Readiness + err error +} + +// Start writes a private Link configuration and funded signer key, starts `ibc +// relayer run`, and returns only after readiness and a /health probe succeed. +func Start(ctx context.Context, spec Spec) (*Relayer, error) { + key, err := validateSpec(spec) + if err != nil { + return nil, err + } + binaryPath, err := filepath.Abs(spec.BinaryPath) + if err != nil { + return nil, fmt.Errorf("start IBC Link relayer: absolute binary path: %w", err) + } + + paths, err := prepareWorkspace(spec, key) + if err != nil { + return nil, err + } + + r := &Relayer{ + signer: crypto.PubkeyToAddress(key.PublicKey), + http: &http.Client{Timeout: statusHTTPTimeout}, + binaryPath: binaryPath, + dir: paths.Dir, + config: spec.Config, + startupTimeout: spec.StartupTimeout, + } + if err := r.launch(ctx, paths.Log); err != nil { + return nil, err + } + return r, nil +} + +// Restart stops the running process and relaunches `ibc relayer run` against the +// same on-disk workspace — the config, signer key, and sqlite/postgres database — +// so relay state persisted before the stop survives. It returns only +// after the new process reports readiness and answers /health. The API bind +// address may change (a fresh ephemeral port), so callers must reach the relayer +// through this handle rather than caching its address. +func (r *Relayer) Restart(ctx context.Context) error { + if err := r.kill(); err != nil { + return fmt.Errorf("restart IBC Link relayer: stop current process: %w", err) + } + r.restarts++ + logPath := filepath.Join(r.dir, fmt.Sprintf("relayer-restart-%d.log", r.restarts)) + if err := r.launch(ctx, logPath); err != nil { + return fmt.Errorf("restart IBC Link relayer: %w", err) + } + return nil +} + +// launch starts one `ibc relayer run` child against the already-prepared +// workspace and blocks until readiness, the chain-connectivity check, and a +// /health probe all succeed. It is shared by Start and Restart; the workspace +// (config, keys, database) must already exist on disk. logPath must not exist. +func (r *Relayer) launch(ctx context.Context, logPath string) error { + out, err := proc.NewLogWriter(logPath) + if err != nil { + return fmt.Errorf("start IBC Link relayer: create log: %w", err) + } + + // Long-lived child: exec.Command (not CommandContext) + Setpgid so Stop can + // signal the whole group. + cmd := exec.Command( + r.binaryPath, + "relayer", "run", + "--home", r.dir, + "--config", configFilename, + ) + cmd.Dir = r.dir + // First stdout line is the readiness JSON; stderr carries human logs and is + // teed to the log file for diagnostics. + stdout, err := cmd.StdoutPipe() + if err != nil { + out.Close() + return fmt.Errorf("start IBC Link relayer: stdout pipe: %w", err) + } + cmd.Stderr = out + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if startErr := cmd.Start(); startErr != nil { + out.Close() + return fmt.Errorf("start IBC Link relayer binary %q: %w", r.binaryPath, startErr) + } + + r.out = out + readyCh := make(chan readyResult, 1) + var drained sync.WaitGroup + drained.Add(1) + go func() { defer drained.Done(); drainStdout(stdout, readyCh) }() + r.handle = proc.Reap(cmd, proc.Hooks{BeforeWait: drained.Wait, AfterWait: out.Close}) + + readiness, err := r.awaitReady(ctx, readyCh, r.startupTimeout) + if err != nil { + return errors.Join(err, r.kill()) + } + if chainsErr := checkChainsConnected(r.config, readiness); chainsErr != nil { + return errors.Join(chainsErr, r.kill()) + } + r.readiness = readiness + r.httpAddr = readiness.Status.HTTP + if healthErr := r.probeHealth(ctx); healthErr != nil { + return errors.Join(healthErr, r.kill()) + } + return nil +} + +// checkChainsConnected fails when the readiness event does not report every +// Chain in the rendered config as connected. Readiness alone only proves the +// process came up; a relayer missing one of its configured chains would never +// relay that lane, so the harness treats an incomplete chain set as a startup +// failure and names the missing chains. +func checkChainsConnected(config Config, readiness wire.Readiness) error { + connected := make(map[string]struct{}, len(readiness.ChainsConnected)) + for _, id := range readiness.ChainsConnected { + connected[id] = struct{}{} + } + var missing []string + for _, chain := range config.Chains { + if _, ok := connected[chain.ChainID]; !ok { + missing = append(missing, chain.ChainID) + } + } + if len(missing) > 0 { + return fmt.Errorf( + "ibc relayer run: readiness reports chains %v connected but is missing requested chains %v", + readiness.ChainsConnected, missing, + ) + } + return nil +} + +// SignerAddress is the Ethereum address derived from the funded relay signer +// key loaded by the child process. +func (r *Relayer) SignerAddress() common.Address { return r.signer } + +// Ready returns the parsed readiness event reported by the process on startup. +func (r *Relayer) Ready() wire.Readiness { return r.readiness } + +func (r *Relayer) Status(ctx context.Context, q wire.StatusQuery) (*wire.Status, error) { + u := r.apiURL(wire.StatusPath) + vals := url.Values{} + if q.RouteID != "" { + vals.Set(wire.StatusQueryRoute, q.RouteID) + } + if q.PacketID != "" { + vals.Set(wire.StatusQueryPacket, q.PacketID) + } + u.RawQuery = vals.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("ibc status: build request: %w", err) + } + var status wire.Status + if err := r.doJSON(req, "ibc status", &status); err != nil { + return nil, err + } + return &status, nil +} + +func (r *Relayer) Relay(ctx context.Context, in wire.RelayRequest) (*wire.RelayResult, error) { + u := r.apiURL(wire.RelayPath) + body, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("ibc relay: encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ibc relay: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + var result wire.RelayResult + if err := r.doJSON(req, "ibc relay", &result); err != nil { + return nil, err + } + return &result, nil +} + +// Stop asks the whole subprocess group to exit and escalates to SIGKILL after a +// fixed grace period or when ctx expires. It is safe to call more than once. +func (r *Relayer) Stop(ctx context.Context) error { + return r.handle.SignalAndWait(ctx, syscall.SIGTERM, stopGrace) +} + +// Kill terminates the subprocess group immediately and waits for the reap. +func (r *Relayer) Kill() error { return r.kill() } + +func (r *Relayer) kill() error { + ctx, cancel := context.WithTimeout(context.Background(), killTimeout) + defer cancel() + return r.handle.SignalAndWait(ctx, syscall.SIGKILL, 0) +} + +// probeHealth pins the /health wire contract at startup; it is not a liveness +// wait. +func (r *Relayer) probeHealth(ctx context.Context) error { + u := r.apiURL(wire.HealthPath) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return fmt.Errorf("ibc relayer run: health: build request: %w", err) + } + return r.doJSON(req, "ibc relayer run: health", nil) +} + +func (r *Relayer) apiURL(path string) url.URL { + return url.URL{Scheme: "http", Host: r.httpAddr, Path: path} +} + +func (r *Relayer) doJSON(req *http.Request, label string, out any) error { + resp, err := r.http.Do(req) + if err != nil { + return fmt.Errorf("%s: %s %s: %w", label, req.Method, req.URL.String(), err) + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyLimit+1)) + if readErr != nil { + return fmt.Errorf( + "%s: %s %s -> %s: read response body: %w", + label, req.Method, req.URL.String(), resp.Status, readErr, + ) + } + bodyText := "" + if len(body) <= errorBodyLimit { + bodyText = strings.TrimSpace(string(body)) + } + return fmt.Errorf("%s: %s %s -> %s: %s", label, req.Method, req.URL.String(), resp.Status, bodyText) + } + if out == nil { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("%s: decode response: %w", label, err) + } + return nil +} + +func (r *Relayer) awaitReady( + ctx context.Context, + readyCh <-chan readyResult, + timeout time.Duration, +) (wire.Readiness, error) { + if timeout <= 0 { + timeout = defaultStartupTimeout + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case res := <-readyCh: + if res.err != nil { + return wire.Readiness{}, fmt.Errorf("ibc relayer run: %w; logs: %s", res.err, r.logTail()) + } + return res.readiness, nil + case <-r.handle.Done(): + processErr := r.handle.Err() + if processErr == nil { + processErr = errors.New("process exited successfully") + } + return wire.Readiness{}, fmt.Errorf( + "ibc relayer run: exited before readiness: %w; logs: %s", processErr, r.logTail(), + ) + case <-timer.C: + return wire.Readiness{}, fmt.Errorf( + "ibc relayer run: not ready within %s; logs: %s", timeout, r.logTail(), + ) + case <-ctx.Done(): + return wire.Readiness{}, fmt.Errorf("ibc relayer run: startup canceled: %w", ctx.Err()) + } +} + +func (r *Relayer) logTail() string { + return strings.TrimSpace(string(r.out.TailSnapshot())) +} + +func drainStdout(rc io.Reader, readyCh chan<- readyResult) { + br := bufio.NewReader(rc) + first := true + for { + line, err := br.ReadString('\n') + if len(line) > 0 && first { + first = false + readyCh <- parseReadiness(line) + } + if err != nil { + if first { + readyCh <- readyResult{err: fmt.Errorf("no readiness line on stdout: %w", err)} + } + return + } + } +} + +func parseReadiness(line string) readyResult { + var readiness wire.Readiness + if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &readiness); err != nil { + return readyResult{ + err: fmt.Errorf("first stdout line is not readiness JSON (%q): %w", strings.TrimSpace(line), err), + } + } + if err := readiness.Validate(); err != nil { + return readyResult{err: fmt.Errorf("invalid readiness: %w", err)} + } + return readyResult{readiness: readiness} +} + +func validateSpec(spec Spec) (*ecdsa.PrivateKey, error) { + switch { + case spec.BinaryPath == "": + return nil, errors.New("start IBC Link relayer: binary path is required") + case spec.WorkDir == "": + return nil, errors.New("start IBC Link relayer: work dir is required") + case spec.RelaySignerKeyHex == "": + return nil, errors.New("start IBC Link relayer: relay signer key is required") + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(spec.RelaySignerKeyHex, "0x")) + if err != nil { + return nil, fmt.Errorf("start IBC Link relayer: parse relay signer key: %w", err) + } + return key, nil +} + +func prepareWorkspace(spec Spec, key *ecdsa.PrivateKey) (proc.WorkspacePaths, error) { + dir, err := filepath.Abs(spec.WorkDir) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link relayer: absolute work dir: %w", err) + } + keysDir, err := proc.CreatePrivateWorkspace(dir) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link relayer: %w", err) + } + keyPath := filepath.Join(keysDir, keyFilename) + if writeErr := proc.WriteECDSAKey(keyPath, key); writeErr != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link relayer: write signer key file: %w", writeErr) + } + + attestorKeyPaths, err := writeAttestorKeys(keysDir, spec.Config.Attestations) + if err != nil { + return proc.WorkspacePaths{}, err + } + + configData, err := renderConfig(spec.Config, signerAlias, keyPath, filepath.Join(dir, dbFilename), attestorKeyPaths) + if err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link relayer: %w", err) + } + if err := proc.WritePrivateFile(filepath.Join(dir, configFilename), configData); err != nil { + return proc.WorkspacePaths{}, fmt.Errorf("start IBC Link relayer: write config: %w", err) + } + return proc.WorkspacePaths{Dir: dir, Log: filepath.Join(dir, logFilename)}, nil +} + +// writeAttestorKeys writes one owner-only ECDSA key file per unique attestation +// signer alias (dual mode) and returns an alias->path map for renderConfig. The +// alias is derived from the attestation name (attestationSignerAlias). The +// pure-relayer path passes no attestations and gets an empty map. +func writeAttestorKeys(keysDir string, attestations []AttestationParams) (map[string]string, error) { + paths := make(map[string]string, len(attestations)) + for _, attestation := range attestations { + if attestation.Name == "" { + return nil, fmt.Errorf("start IBC Link relayer: attestation requires a name") + } + alias := attestationSignerAlias(attestation.Name) + if _, done := paths[alias]; done { + continue + } + if attestation.PrivateKeyHex == "" { + return nil, fmt.Errorf( + "start IBC Link relayer: attestation signer %q requires a private key", alias, + ) + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(attestation.PrivateKeyHex, "0x")) + if err != nil { + return nil, fmt.Errorf( + "start IBC Link relayer: parse attestation signer %q key: %w", alias, err, + ) + } + keyPath := filepath.Join(keysDir, "attestor-"+strconv.Itoa(len(paths))+".json") + if writeErr := proc.WriteECDSAKey(keyPath, key); writeErr != nil { + return nil, fmt.Errorf( + "start IBC Link relayer: write attestation signer %q key: %w", alias, writeErr, + ) + } + paths[alias] = keyPath + } + return paths, nil +} diff --git a/e2e/internal/harness/relayerproc/relayer_test.go b/e2e/internal/harness/relayerproc/relayer_test.go new file mode 100644 index 000000000..962a287e3 --- /dev/null +++ b/e2e/internal/harness/relayerproc/relayer_test.go @@ -0,0 +1,336 @@ +package relayerproc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +const testPrivateKey = "0000000000000000000000000000000000000000000000000000000000000006" + +func testConfig() Config { + return Config{ + Chains: []ChainParams{ + {ChainID: "31337", RPC: "http://127.0.0.1:8545", ICS26Router: "0xaaaa000000000000000000000000000000000001"}, + {ChainID: "31338", RPC: "http://127.0.0.1:8546", ICS26Router: "0xbbbb000000000000000000000000000000000002"}, + }, + Clients: []ClientParams{ + { + Alias: "client-a", ClientID: "client-a-onchain", ChainID: "31337", + CounterpartyChainID: "31338", CounterpartyClientID: "client-b-onchain", Threshold: 1, + Attestors: []AttestorRef{{ + Name: "attestor-a", GRPC: "127.0.0.1:1111", + EVMAddress: "0xaaaa000000000000000000000000000000000011", + }}, + }, + { + Alias: "client-b", ClientID: "client-b-onchain", ChainID: "31338", + CounterpartyChainID: "31337", CounterpartyClientID: "client-a-onchain", Threshold: 1, + Attestors: []AttestorRef{{ + Name: "attestor-b", GRPC: "127.0.0.1:2222", + EVMAddress: "0xbbbb000000000000000000000000000000000022", + }}, + }, + }, + Routes: []RouteParams{{SourceClient: "client-a"}, {SourceClient: "client-b"}}, + } +} + +func TestStartParsesReadinessProbesHealthAndStopsProcess(t *testing.T) { + binary := helperBinary(t) + workDir := filepath.Join(t.TempDir(), "relayer") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + relayer, err := Start(ctx, Spec{ + BinaryPath: binary, + WorkDir: workDir, + RelaySignerKeyHex: testPrivateKey, + Config: testConfig(), + }) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0xE57bFE9F44b819898F47BF37E5AF72a0783e1141"), relayer.SignerAddress()) + + require.NoError(t, relayer.Ready().Validate()) + require.NotEmpty(t, relayer.Ready().Status.HTTP) + + status, err := relayer.Status(ctx, wire.StatusQuery{}) + require.NoError(t, err) + require.Len(t, status.Packets, 1) + require.Equal(t, wire.RelayerPacketID("client-a", 1), status.Packets[0].PacketID) + + result, err := relayer.Relay(ctx, wire.RelayRequest{SourceChainID: "31337", SourceTxHash: "0xdead"}) + require.NoError(t, err) + require.Equal(t, []string{wire.RelayerPacketID("client-a", 1)}, result.PacketIDs) + + configInfo, err := os.Stat(filepath.Join(workDir, configFilename)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), configInfo.Mode().Perm()) + workDirInfo, err := os.Stat(workDir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), workDirInfo.Mode().Perm()) + keyInfo, err := os.Stat(filepath.Join(workDir, "keys", keyFilename)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), keyInfo.Mode().Perm()) + + childPIDData, err := os.ReadFile(filepath.Join(workDir, "helper-child.pid")) + require.NoError(t, err) + childPID, err := strconv.Atoi(strings.TrimSpace(string(childPIDData))) + require.NoError(t, err) + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + require.NoError(t, relayer.Stop(stopCtx)) + require.NoError(t, relayer.Stop(stopCtx), "Stop must be idempotent") + require.ErrorIs(t, syscall.Kill(childPID, 0), syscall.ESRCH, "Stop must signal descendants in the process group") + + logs, err := os.ReadFile(filepath.Join(workDir, logFilename)) + require.NoError(t, err) + require.Contains(t, string(logs), "helper stopped") +} + +func TestStartFailsWhenReadinessOmitsARequestedChain(t *testing.T) { + binary := helperBinary(t) + // The fake binary reports only 31337 and 31338 connected; adding a third + // configured chain the readiness line never mentions must fail startup. + config := testConfig() + config.Chains = append(config.Chains, ChainParams{ + ChainID: "31339", + RPC: "http://127.0.0.1:8547", + ICS26Router: "0xcccc000000000000000000000000000000000003", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Start(ctx, Spec{ + BinaryPath: binary, + WorkDir: filepath.Join(t.TempDir(), "relayer"), + RelaySignerKeyHex: testPrivateKey, + Config: config, + }) + require.Error(t, err) + require.ErrorContains(t, err, "missing requested chains") + require.ErrorContains(t, err, "31339") +} + +func TestStartReportsEarlyProcessExitWithLogs(t *testing.T) { + binary := filepath.Join(t.TempDir(), "ibc-fail") + require.NoError(t, os.WriteFile(binary, []byte("#!/bin/sh\necho deliberate-start-failure >&2\nexit 17\n"), 0o700)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Start(ctx, Spec{ + BinaryPath: binary, + WorkDir: filepath.Join(t.TempDir(), "relayer"), + RelaySignerKeyHex: testPrivateKey, + Config: testConfig(), + }) + require.Error(t, err) + // Depending on which signal lands first, the process either exits before + // readiness or closes stdout without a readiness line; both carry the logs. + require.ErrorContains(t, err, "readiness") + require.ErrorContains(t, err, "deliberate-start-failure") +} + +func TestStartRejectsInvalidInputsBeforeCreatingWorkspace(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "relayer") + _, err := Start(context.Background(), Spec{ + BinaryPath: "/definitely/not/a/binary", + WorkDir: workDir, + RelaySignerKeyHex: "not-a-key", + Config: testConfig(), + }) + require.ErrorContains(t, err, "parse relay signer key") + _, statErr := os.Stat(workDir) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestStartRequiresFreshPrivateWorkspace(t *testing.T) { + workDir := t.TempDir() + _, err := Start(context.Background(), Spec{ + BinaryPath: "/definitely/not/a/binary", + WorkDir: workDir, + RelaySignerKeyHex: testPrivateKey, + Config: testConfig(), + }) + require.ErrorContains(t, err, "create private work dir") +} + +func TestParseReadinessAcceptsValidAndRejectsMalformed(t *testing.T) { + valid := `{"event":"ready","configLoaded":true,"dbReady":true,"relayerSubscribed":true,"status":{"http":"127.0.0.1:5000"}}` + res := parseReadiness(valid) + require.NoError(t, res.err) + require.Equal(t, "127.0.0.1:5000", res.readiness.Status.HTTP) + + require.ErrorContains(t, parseReadiness("not json").err, "not readiness JSON") + require.ErrorContains(t, parseReadiness(`{"event":"nope"}`).err, "invalid readiness") +} + +func TestStartRealIBCBinary(t *testing.T) { + binary := os.Getenv("IBC_LINK_RELAYER_REAL_BIN") + if binary == "" { + t.Skip("set IBC_LINK_RELAYER_REAL_BIN to exercise a built Link binary") + } + absoluteBinary, err := filepath.Abs(binary) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + relayer, err := Start(ctx, Spec{ + BinaryPath: absoluteBinary, + WorkDir: filepath.Join(t.TempDir(), "relayer"), + RelaySignerKeyHex: testPrivateKey, + Config: testConfig(), + }) + require.NoError(t, err) + require.NoError(t, relayer.Ready().Validate()) + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + require.NoError(t, relayer.Stop(stopCtx)) +} + +func TestIBCLinkRelayerHelperProcess(_ *testing.T) { + if os.Getenv("IBC_LINK_RELAYER_HELPER") != "1" { + return + } + if err := runRelayerHelper(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } +} + +func helperBinary(t *testing.T) string { + t.Helper() + testBinary, err := os.Executable() + require.NoError(t, err) + t.Setenv("IBC_LINK_RELAYER_TEST_BINARY", testBinary) + + path := filepath.Join(t.TempDir(), "ibc-helper") + script := `#!/bin/sh +IBC_LINK_RELAYER_HELPER=1 exec "$IBC_LINK_RELAYER_TEST_BINARY" -test.run '^TestIBCLinkRelayerHelperProcess$' -- "$@" +` + require.NoError(t, os.WriteFile(path, []byte(script), 0o700)) + return path +} + +func runRelayerHelper() error { + home, configName, err := helperConfigArgs(os.Args) + if err != nil { + return err + } + configData, err := os.ReadFile(filepath.Join(home, configName)) + if err != nil { + return fmt.Errorf("helper read config: %w", err) + } + if !strings.Contains(string(configData), "signer: "+signerAlias) { + return fmt.Errorf("helper config missing relay signer alias:\n%s", configData) + } + if !strings.Contains(string(configData), "type: attestation") { + return fmt.Errorf("helper config missing attestation clients:\n%s", configData) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("helper listen: %w", err) + } + mux := http.NewServeMux() + mux.HandleFunc(wire.HealthPath, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{}`) + }) + mux.HandleFunc(wire.StatusPath, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wire.Status{Packets: []wire.PacketStatus{{ + PacketID: wire.RelayerPacketID("client-a", 1), RouteID: "client-a", Sequence: 1, State: wire.PacketPending, + }}}) + }) + mux.HandleFunc(wire.RelayPath, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wire.RelayResult{PacketIDs: []string{wire.RelayerPacketID("client-a", 1)}}) + }) + server := &http.Server{Handler: mux} + + child := exec.Command("sleep", "60") + if startErr := child.Start(); startErr != nil { + return fmt.Errorf("helper start descendant: %w", startErr) + } + if writeErr := os.WriteFile( + filepath.Join(home, "helper-child.pid"), + []byte(strconv.Itoa(child.Process.Pid)+"\n"), + 0o600, + ); writeErr != nil { + return fmt.Errorf("helper write descendant pid: %w", writeErr) + } + childDone := make(chan error, 1) + go func() { childDone <- child.Wait() }() + + // The readiness line must be the first stdout line and carry the real, + // ephemeral HTTP address, mirroring `ibc relayer run`. + readiness := wire.Readiness{ + Event: wire.ReadinessEvent, + ConfigLoaded: true, + DBReady: true, + ChainsConnected: []string{"31337", "31338"}, + RelayerSubscribed: true, + Status: wire.ReadinessStatus{HTTP: listener.Addr().String()}, + } + line, err := json.Marshal(readiness) + if err != nil { + return fmt.Errorf("helper encode readiness: %w", err) + } + fmt.Println(string(line)) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + fmt.Fprintln(os.Stderr, "helper listening") + done := make(chan error, 1) + go func() { done <- server.Serve(listener) }() + select { + case <-signals: + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("helper shutdown: %w", err) + } + select { + case <-childDone: + case <-time.After(time.Second): + return fmt.Errorf("helper descendant did not receive process-group signal") + } + fmt.Fprintln(os.Stderr, "helper stopped") + return nil + case err := <-done: + return fmt.Errorf("helper server exited: %w", err) + } +} + +func helperConfigArgs(args []string) (string, string, error) { + var home, config string + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "--home": + home = args[i+1] + case "--config": + config = args[i+1] + } + } + if home == "" || config == "" { + return "", "", fmt.Errorf("helper requires --home and --config, got %q", args) + } + return home, config, nil +} diff --git a/e2e/protocol/await_test.go b/e2e/protocol/await_test.go new file mode 100644 index 000000000..138dd8927 --- /dev/null +++ b/e2e/protocol/await_test.go @@ -0,0 +1,114 @@ +package protocol_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// awaitPacketState polls the Relayer's keyed /status for a single packet until it +// reaches want. Any keyed /status error is an immediate failure (a transitioning +// packet must never yield a 5xx). It also fails on the budget elapsing, on a +// terminal state other than want, or on a state the harness does not model. It +// returns the terminal PacketStatus so callers can assert its recorded effect and +// source transactions. The unfiltered and route-filtered /status views are +// exercised separately by TestStatus_ViewsStayAvailable, not on every poll here. +func awaitPacketState( + t *testing.T, + env *environment.Environment, + id string, + sourceTxHash string, + want wire.PacketState, + budget environment.Timing, +) wire.PacketStatus { + t.Helper() + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + deadline := time.Now().Add(budget.CompletionBudget) + var last wire.PacketStatus + var found bool + for time.Now().Before(deadline) { + status, statusErr := relayer.Status(t.Context(), wire.StatusQuery{PacketID: id}) + require.NoError(t, statusErr, "keyed /status must not error while a packet is transitioning") + + if packet, ok := status.Packet(id); ok { + last, found = packet, true + switch packet.State { + case want: + require.True(t, equalHex(packet.SourceTxHash, sourceTxHash), + "packet %s reports source tx %s, want %s", id, packet.SourceTxHash, sourceTxHash) + return packet + case wire.PacketPending: + // Not terminal yet; keep waiting. + case wire.PacketComplete, wire.PacketTimedOut, wire.PacketErrorAck, wire.PacketFailed: + t.Fatalf("packet %s reached terminal state %q, want %q: %+v", id, packet.State, want, packet) + default: + // A state the harness does not model must fail loudly rather than be + // treated as "still pending" and silently spin until the budget elapses. + t.Fatalf("packet %s reported unrecognized state %q: %+v", id, packet.State, packet) + } + } + time.Sleep(budget.PollInterval) + } + if found { + t.Fatalf("packet %s did not reach %q within %s; last observed: %+v", id, want, budget.CompletionBudget, last) + } + t.Fatalf("packet %s did not appear within %s", id, budget.CompletionBudget) + return wire.PacketStatus{} +} + +// observePacketNeverTerminal proves a discovered packet stays pending: it waits +// (up to the completion budget) for the packet to appear on the keyed /status, +// then polls across the chain's settle window and fails if the packet ever +// reaches a completed or terminal-failure state. Unlike a bare "not complete" +// check it REQUIRES the packet to be observed, so a relayer that silently +// dropped the packet cannot pass. /status must answer 200 throughout. +// +// First observation gets the completion budget rather than the settle window: +// discovery may lag when the relayer is riding out an injected fault (its scan +// pass can burn an RPC timeout against a hung chain before reaching the source +// chain). The never-terminal window only starts once the packet is visible. +func observePacketNeverTerminal(t *testing.T, env *environment.Environment, id string, budget environment.Timing) { + t.Helper() + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + appearDeadline := time.Now().Add(budget.CompletionBudget) + observed := false + for !observed && time.Now().Before(appearDeadline) { + status, statusErr := relayer.Status(t.Context(), wire.StatusQuery{PacketID: id}) + require.NoError(t, statusErr, "/status must not error while a packet is pending") + _, observed = status.Packet(id) + if !observed { + time.Sleep(budget.PollInterval) + } + } + require.True(t, observed, "packet %s was never observed on /status within the completion budget", id) + + deadline := time.Now().Add(budget.SettleWindow) + for time.Now().Before(deadline) { + status, err := relayer.Status(t.Context(), wire.StatusQuery{PacketID: id}) + require.NoError(t, err, "/status must not error while a packet is pending") + if packet, ok := status.Packet(id); ok { + switch packet.State { + case wire.PacketPending: + // Still pending, as required. + case wire.PacketComplete: + t.Fatalf("packet %s unexpectedly completed while it should stay pending: %+v", id, packet) + case wire.PacketTimedOut, wire.PacketErrorAck, wire.PacketFailed: + t.Fatalf("packet %s reached terminal failure %q: %+v", id, packet.State, packet) + default: + // An unrecognized state must never be silently accepted as "pending": a + // terminal state the harness does not yet model would otherwise pass this + // assertion after the packet had actually resolved. + t.Fatalf("packet %s reported unrecognized state %q: %+v", id, packet.State, packet) + } + } + time.Sleep(budget.PollInterval) + } +} diff --git a/e2e/protocol/batch_test.go b/e2e/protocol/batch_test.go new file mode 100644 index 000000000..e95721f55 --- /dev/null +++ b/e2e/protocol/batch_test.go @@ -0,0 +1,74 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +const batchColdSendCount = 2 + +func TestIFT_RealRelayer_BatchedColdSend(t *testing.T) { + fx := newFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + perTransfer := big.NewInt(1_000_000) + total := new(big.Int).Mul(perTransfer, big.NewInt(int64(batchColdSendCount))) + require.NoError(t, iftA.Mint(ctx, deployerAddr, total)) + + // Both packets must predate the relayer's first scan to exercise batching. + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + require.NoError(t, relayer.Kill(), "kill the relayer before the sends so all packets predate it") + + transfers := make([]environment.IFTTransferReceipt, 0, batchColdSendCount) + for i := 0; i < batchColdSendCount; i++ { + transfer, transferErr := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: perTransfer, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, transferErr) + require.NotEmpty(t, transfer.SourceTxHash) + transfers = append(transfers, transfer) + } + + require.NoError(t, relayer.Restart(ctx)) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + + var firstEffectHash string + for i, transfer := range transfers { + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmptyf(t, packet.EffectTxHash, "packet %s records its destination recv tx", id) + require.Equal(t, transfer.Sequence, packet.Sequence) + if i == 0 { + firstEffectHash = packet.EffectTxHash + continue + } + require.Truef(t, equalHex(firstEffectHash, packet.EffectTxHash), + "packet %d reports recv tx %s but packet 0 reports %s; the route was not delivered in a single batch tx", + i, packet.EffectTxHash, firstEffectHash) + } + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, total.Cmp(destAfter), "receiver is credited the full batched amount") +} diff --git a/e2e/protocol/fixture_test.go b/e2e/protocol/fixture_test.go new file mode 100644 index 000000000..0de7283c1 --- /dev/null +++ b/e2e/protocol/fixture_test.go @@ -0,0 +1,238 @@ +package protocol_test + +import ( + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// Authored graph identities for the truthful IFT lane. Two managed EVM chains +// (Anvil or Besu per the selected lane), one IBC Instance each, one Connection +// with attestation clients in both directions, one Attestor per client end, and +// one Relayer serving the Connection. +const ( + chainA = e2etest.ChainA + chainB = e2etest.ChainB + instanceA = environment.IBCInstanceID("ibc-a") + instanceB = environment.IBCInstanceID("ibc-b") + connID = environment.ConnectionID("a-b") + clientA = environment.ClientID("client-a") + clientB = environment.ClientID("client-b") + attestorA = environment.AttestorID("attestor-a") + attestorB = environment.AttestorID("attestor-b") + relayerID = environment.RelayerID("relayer-ab") + + deployer = environment.AuthorityID("deployer") + signerA = environment.AuthorityID("attestor-signer-a") + signerB = environment.AuthorityID("attestor-signer-b") + relaySigner = environment.AuthorityID("relay-signer") + + tokenName = "Interop Fungible Token" + tokenSymbol = "IFT" +) + +// Deterministic non-default identities. Attestor signers never submit +// transactions; the deployer and relay signer do and are funded on both chains +// (the deployer by realization, the relay signer by the test). +const ( + deployerKey = "0000000000000000000000000000000000000000000000000000000000000005" + signerAKey = "0000000000000000000000000000000000000000000000000000000000000001" + signerBKey = "0000000000000000000000000000000000000000000000000000000000000002" + relaySignerKey = "0000000000000000000000000000000000000000000000000000000000000003" +) + +// fundingMinimum is generous relative to the handful of relay transactions an +// auto-relayed transfer submits per chain. +var fundingMinimum = new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)) + +type fixture struct { + suite e2etest.Suite + receiver common.Address + // chainAEVMID is the EVM chain-id the selected lane assigned to chain A. The + // manual-relay test names it as the relay request's source chain-id. + chainAEVMID uint64 +} + +// laneChainSpecs builds the two IFT chains for the selected lane and returns the +// EVM chain-id assigned to chain A. Each lane uses a distinct EVM chain-id pair so +// lanes running in parallel never collide. The chain type (Anvil instant, Anvil +// interval, or Besu) follows the selected lane via e2etest.LaneChainSpec. +func laneChainSpecs(t *testing.T) (specs []environment.ChainSpec, chainAEVMID uint64) { + t.Helper() + var evmA, evmB uint64 + switch e2etest.SelectedLane(t) { + case e2etest.LaneBesu: + evmA, evmB = 62337, 62338 + case e2etest.LaneAnvilInterval: + evmA, evmB = 61437, 61438 + default: + evmA, evmB = 61337, 61338 + } + return []environment.ChainSpec{ + e2etest.LaneChainSpec(t, chainA, evmA), + e2etest.LaneChainSpec(t, chainB, evmB), + }, evmA +} + +// baseSpec builds the standard two-chain, two-instance, single-Connection lane +// with one Relayer and the deployer and relay-signer runtime authorities every +// test needs. Tests mutate the returned spec (attestor topology, client +// thresholds, relayer DB/embed/auto flags, additional Connections) and add their +// own attestor-signer authorities to the runtime before building a fixture. +func baseSpec(t *testing.T) (environment.Spec, environment.Runtime, uint64) { + t.Helper() + chains, chainAEVMID := laneChainSpecs(t) + spec := environment.Spec{ + Chains: chains, + IBCInstances: []environment.IBCInstanceSpec{ + environment.NewIBCInstance{ID: instanceA, Chain: chainA, Authority: deployer}, + environment.NewIBCInstance{ID: instanceB, Chain: chainB, Authority: deployer}, + }, + Connections: []environment.ConnectionSpec{{ + ID: connID, + A: environment.NewClient{ + ID: clientA, IBCInstance: instanceA, Authority: deployer, MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: clientB, IBCInstance: instanceB, Authority: deployer, MinRequiredSignatures: 1, + }, + }}, + Relayers: []environment.RelayerSpec{{ + ID: relayerID, Connections: []environment.ConnectionID{connID}, Authority: relaySigner, + }}, + } + runtime := environment.Runtime{Authorities: map[environment.AuthorityID]environment.EVMAuthority{ + deployer: {PrivateKeyHex: deployerKey}, + relaySigner: {PrivateKeyHex: relaySignerKey}, + }} + return spec, runtime, chainAEVMID +} + +// withStandardAttestors attaches the default one-Attestor-per-client topology +// (attestor-a on client-a, attestor-b on client-b) and registers their signer +// keys on the runtime. +func withStandardAttestors(spec *environment.Spec, runtime environment.Runtime) { + spec.Attestors = []environment.AttestorSpec{ + {ID: attestorA, Client: clientA, Authority: signerA}, + {ID: attestorB, Client: clientB, Authority: signerB}, + } + runtime.Authorities[signerA] = environment.EVMAuthority{PrivateKeyHex: signerAKey} + runtime.Authorities[signerB] = environment.EVMAuthority{PrivateKeyHex: signerBKey} +} + +// newFixture builds the truthful IFT lane selection. autoRelay selects whether +// the Relayer relays automatically or waits for an explicit manual relay. +func newFixture(t *testing.T, autoRelay bool) fixture { + return newFixtureWith(t, autoRelay, false) +} + +// newFixtureWith builds the truthful IFT lane. embedAttestors selects dual mode: +// the Attestors serving the Connection are folded into the single `ibc relayer +// run` process instead of running as separate attestor processes. +func newFixtureWith(t *testing.T, autoRelay, embedAttestors bool) fixture { + t.Helper() + spec, runtime, chainAEVMID := baseSpec(t) + auto := autoRelay + spec.Relayers[0].AutoRelay = &auto + spec.Relayers[0].EmbedAttestors = embedAttestors + withStandardAttestors(&spec, runtime) + return fixture{ + suite: e2etest.SuiteFor(spec, runtime), + receiver: newAddress(t), + chainAEVMID: chainAEVMID, + } +} + +// fundRelaySigner funds the Relayer's signing account on both Chains. Realization +// funds protocol authorities but not the Relayer authority, so the test must do +// it before any packet needs relaying. +func fundRelaySigner(t *testing.T, env *environment.Environment) { + t.Helper() + address, err := env.AuthorityAddress(relaySigner) + require.NoError(t, err) + target := common.HexToAddress(string(address)) + for _, id := range []environment.ChainID{chainA, chainB} { + chain, err := env.Chain(id) + require.NoError(t, err) + funding, err := chain.Funding() + require.NoError(t, err) + require.NoError(t, funding.EnsureEOABalance(t.Context(), target, fundingMinimum)) + } +} + +// deployBridgedIFT deploys the IFT apps on both chains, authorizes public +// relaying on both routers, and cross-registers the token bridges. It returns the +// two token handles and the resolved Connection. +func deployBridgedIFT( + t *testing.T, + env *environment.Environment, +) (iftA *environment.IFTApp, iftB *environment.IFTApp, conn *environment.Connection) { + t.Helper() + ctx := t.Context() + + require.NoError(t, env.AuthorizePublicRelay(ctx, instanceA, deployer)) + require.NoError(t, env.AuthorizePublicRelay(ctx, instanceB, deployer)) + + iftA, err := env.DeployIFT(ctx, environment.IFTRequest{ + Instance: instanceA, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, err) + iftB, err = env.DeployIFT(ctx, environment.IFTRequest{ + Instance: instanceB, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, err) + + conn, err = env.Connection(connID) + require.NoError(t, err) + + require.NoError(t, iftA.RegisterBridge(ctx, conn.A().Locator(), iftB.Address())) + require.NoError(t, iftB.RegisterBridge(ctx, conn.B().Locator(), iftA.Address())) + return iftA, iftB, conn +} + +// futureTimeout is a transfer timeout comfortably beyond the relay budget. +func futureTimeout() uint64 { + return uint64(time.Now().Add(time.Hour).Unix()) +} + +// packetID is the relayer's status identifier for a packet. The harness sets the +// relayer's source-client alias to the authored IBC Client id, so the canonical +// real-daemon packet id composes from the resolved Client and the send sequence. +func packetID(client *environment.IBCClient, sequence uint64) string { + return wire.RelayerPacketID(string(client.ID()), sequence) +} + +// routeIDOf returns the route id embedded in a "-" packet id. +func routeIDOf(id string) string { + if i := strings.LastIndex(id, "-"); i > 0 { + return id[:i] + } + return id +} + +func equalHex(a, b string) bool { + return normalizeHex(a) == normalizeHex(b) +} + +func normalizeHex(s string) string { + if len(s) >= 2 && (s[:2] == "0x" || s[:2] == "0X") { + s = s[2:] + } + out := make([]byte, len(s)) + for i := range s { + c := s[i] + if c >= 'A' && c <= 'F' { + c += 'a' - 'A' + } + out[i] = c + } + return string(out) +} diff --git a/e2e/protocol/gmp_test.go b/e2e/protocol/gmp_test.go new file mode 100644 index 000000000..686af80cc --- /dev/null +++ b/e2e/protocol/gmp_test.go @@ -0,0 +1,112 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// gmpBadCalldata is a 4-byte selector that matches no IFT function, so the +// destination ICS27 account's call reverts and the router writes a universal +// error acknowledgement. +var gmpBadCalldata = []byte{0xde, 0xad, 0xbe, 0xef} + +// TestGMP_RealRelayer_Delivery proves a real ICS27 GMP call from chain A executes +// on chain B through `ibc relayer run`. The GMP call targets the destination IFT's +// ERC20 transfer, executed by the deterministic ICS27 account the source sender +// controls on the destination: the test pre-funds that account with IFT-B, sends +// the GMP call over the real relayer, and asserts the packet completes and the +// tokens actually moved from the ICS27 account to the receiver on chain B. +func TestGMP_RealRelayer_Delivery(t *testing.T) { + fx := newFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + senderAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + // The GMP call executes through the ICS27 account the source sender controls + // on the destination. It is keyed by the destination client the packet arrives + // over and the checksummed source sender address. + destClient := string(conn.A().CounterpartyLocator()) + account, err := iftB.ICS27Account(ctx, destClient, senderAddr) + require.NoError(t, err) + require.NotEqual(t, environment.EVMAddress(""), account) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftB.Mint(ctx, account, amount), "fund the ICS27 account the GMP call acts through") + accountBefore, err := iftB.BalanceOf(ctx, account) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(accountBefore), "ICS27 account holds the funded balance before delivery") + receiverBefore, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, receiverBefore.Sign(), "receiver holds nothing before delivery") + + payload, err := environment.TransferCalldata(receiver, amount) + require.NoError(t, err) + + call, err := iftA.GMPCall(ctx, environment.GMPCallRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: iftB.Address(), + Payload: payload, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + require.NotEmpty(t, call.SourceTxHash) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), call.Sequence) + packet := awaitPacketState(t, env, id, call.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "completed GMP packet records its destination effect tx") + + receiverAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(receiverAfter), "GMP call transferred the tokens to the receiver on destination") + accountAfter, err := iftB.BalanceOf(ctx, account) + require.NoError(t, err) + require.Equal(t, 0, accountAfter.Sign(), "ICS27 account was drained by the executed GMP call") +} + +// TestGMP_RealRelayer_ErrorAck proves the real universal-error-acknowledgement +// path end to end: a GMP call whose payload reverts on the destination causes the +// router to write a universal error ack, which the real relayer relays back so the +// packet reaches the terminal error_ack state with the source-side ack tx as its +// effect. +func TestGMP_RealRelayer_ErrorAck(t *testing.T) { + fx := newFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + // The payload targets the IFT with a selector it does not implement; the + // destination account's call reverts, producing a universal error ack. + call, err := iftA.GMPCall(ctx, environment.GMPCallRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: iftB.Address(), + Payload: gmpBadCalldata, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + require.NotEmpty(t, call.SourceTxHash) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), call.Sequence) + packet := awaitPacketState(t, env, id, call.SourceTxHash, wire.PacketErrorAck, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "error-acked packet records its source-side ack tx") + require.Equal(t, call.Sequence, packet.Sequence) +} diff --git a/e2e/protocol/ift_test.go b/e2e/protocol/ift_test.go new file mode 100644 index 000000000..a1b28a08b --- /dev/null +++ b/e2e/protocol/ift_test.go @@ -0,0 +1,194 @@ +package protocol_test + +import ( + "math/big" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestIFT_RealRelayer_AutoRelay relays a real IFT transfer end to end with the +// Attestors running as separate `ibc attestor run` processes. The test name is a +// lane-matrix entry point. +func TestIFT_RealRelayer_AutoRelay(t *testing.T) { + runRealRelayerAutoIFT(t, false) +} + +// TestIFT_RealRelayer_DualMode relays the same real IFT transfer in dual mode: a +// single `ibc relayer run` process runs the relayer, attestor, and signer from +// one config, with no separate attestor process; the relayer's attestation +// clients are fed by its own in-process attestors over a local (no-network) +// attestor set. The test name is a lane-matrix entry point. +func TestIFT_RealRelayer_DualMode(t *testing.T) { + runRealRelayerAutoIFT(t, true) +} + +// runRealRelayerAutoIFT proves a real IFT transfer relayed end to end by `ibc +// relayer run` over real IBC: real ICS26 routers on both lane chains, attestation +// light clients fed by real attestors, and the real relayer auto-relaying the +// packet. embedAttestors selects dual mode, folding the Attestors into the single +// relayer process. It asserts the on-chain token effect on both sides. +func runRealRelayerAutoIFT(t *testing.T, embedAttestors bool) { + t.Helper() + fx := newFixtureWith(t, true, embedAttestors) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + sourceBefore, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceBefore), "sender holds the minted balance before transfer") + destBefore, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, destBefore.Sign(), "receiver holds nothing before transfer") + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + require.NotEmpty(t, transfer.SourceTxHash) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + + require.NotEmpty(t, packet.EffectTxHash, "completed packet records its destination effect tx") + require.Equal(t, transfer.Sequence, packet.Sequence) + + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, sourceAfter.Sign(), "source balance is burned on send") + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited on destination recv") +} + +// TestIFT_RealRelayer_ManualRelay proves the same real path with auto-relay +// disabled: the packet stays pending until an explicit POST /relay drives it to +// completion. +func TestIFT_RealRelayer_ManualRelay(t *testing.T) { + fx := newFixture(t, false) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(2_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + // A manual relay for a syntactically valid but nonexistent source tx is + // rejected: the daemon finds no packet at the hash and answers 404. + _, unknownErr := relayer.Relay(ctx, wire.RelayRequest{ + SourceChainID: strconv.FormatUint(fx.chainAEVMID, 10), + SourceTxHash: "0x00000000000000000000000000000000000000000000000000000000deadbeef", + }) + require.ErrorContains(t, unknownErr, "no packet found", + "manual relay of an unknown source tx must surface the daemon's not-found error") + + // With auto-relay disabled the real binary does not track the packet until a + // relay is requested: it must not have completed on its own before the manual + // request drives it. + requireNotComplete(t, relayer, id, iftB, receiver, destChain.Timing()) + + result, err := relayer.Relay(ctx, wire.RelayRequest{ + SourceChainID: strconv.FormatUint(fx.chainAEVMID, 10), + SourceTxHash: transfer.SourceTxHash, + }) + require.NoError(t, err) + require.NotEmpty(t, result.PacketIDs, "manual relay reports the packets it drove") + require.Contains(t, result.PacketIDs, id, "manual relay drives the transfer's packet") + + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash) + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited after manual relay") + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, sourceAfter.Sign()) +} + +// requireNotComplete proves that, with auto-relay disabled, the packet never +// advances on its own before a manual relay is requested. It polls the +// relayer's /status at the poll interval across the chain's settle window: the +// endpoint may not answer on the first probe, but once it has answered +// successfully every later probe must also succeed (no error is tolerated after +// the endpoint's first readiness), and every observed packet must be absent or +// still pending for the whole window. Immediately before the caller issues POST +// /relay it also asserts the destination receiver still holds nothing. +func requireNotComplete( + t *testing.T, + relayer *environment.Relayer, + id string, + dest *environment.IFTApp, + receiver environment.EVMAddress, + timing environment.Timing, +) { + t.Helper() + ctx := t.Context() + + deadline := time.Now().Add(timing.SettleWindow) + var endpointReady bool + for time.Now().Before(deadline) { + status, err := relayer.Status(ctx, wire.StatusQuery{PacketID: id}) + if err != nil { + require.False(t, endpointReady, + "relayer /status errored after it had already answered while awaiting manual relay: %v", err) + time.Sleep(timing.PollInterval) + continue + } + endpointReady = true + if packet, ok := status.Packet(id); ok { + require.Equal(t, wire.PacketPending, packet.State, + "packet must be absent or pending until a manual relay request, got: %+v", packet) + } + time.Sleep(timing.PollInterval) + } + require.True(t, endpointReady, "relayer /status never answered within the settle window") + + balance, err := dest.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, balance.Sign(), + "receiver holds tokens before any manual relay request: %s", balance) +} diff --git a/e2e/protocol/lookback_test.go b/e2e/protocol/lookback_test.go new file mode 100644 index 000000000..6b51b2894 --- /dev/null +++ b/e2e/protocol/lookback_test.go @@ -0,0 +1,61 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestIFT_RealRelayer_ColdSendBackfill proves the relayer discovers and completes +// a packet that was sent while it was not running: no manual /relay is ever +// issued, so the auto-relay startup lookback alone must find the pre-existing +// send and drive it to completion. The send lands at the tip of the source chain, +// well inside the relayer's default lookback window. +func TestIFT_RealRelayer_ColdSendBackfill(t *testing.T) { + fx := newFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + require.NoError(t, relayer.Kill(), "kill the relayer before the send so the packet predates it") + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + require.NoError(t, relayer.Restart(ctx)) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "backfilled packet records its destination effect tx") + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited by the lookback backfill alone") + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, sourceAfter.Sign(), "source balance stays burned") +} diff --git a/e2e/protocol/pending_test.go b/e2e/protocol/pending_test.go new file mode 100644 index 000000000..9329231e5 --- /dev/null +++ b/e2e/protocol/pending_test.go @@ -0,0 +1,81 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestPending_StatusStaysTruthfulUntilDelivered proves the relayer's /status +// tells the truth about a packet the destination has not yet accepted. With +// destination mining paused, an auto-relayed IFT transfer's recv transaction +// lands in the destination mempool but cannot be mined; /status must report the +// packet as pending — never terminal — for the whole settle window, and the +// receiver must hold nothing. Once a single block is mined the packet completes +// and the receiver is credited. It requires environment-owned mining control on +// the destination (Managed Anvil instant mining) and skips on lanes that mine on +// a fixed interval (Anvil interval, Besu). +func TestPending_StatusStaysTruthfulUntilDelivered(t *testing.T) { + fx := newFixture(t, true) + e2etest.RequireCapabilities(t, fx.suite, environment.Requirements{ + MiningControl: []environment.ChainID{chainB}, + }) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + mining, err := destChain.Mining() + require.NoError(t, err) + dst, err := destChain.EVM() + require.NoError(t, err) + + // Everything from the send onward runs with the destination's block production + // paused, so the relayer's recv transaction cannot land until the test mines. + require.NoError(t, mining.WithPaused(ctx, func() error { + transfer, transferErr := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, transferErr) + + id := packetID(conn.A(), transfer.Sequence) + + // The relayer's recv sits unmined in the destination mempool; seeing it there + // proves the packet is genuinely in flight before asserting it stays pending. + require.NoError(t, dst.WaitPendingTx(ctx)) + + observePacketNeverTerminal(t, env, id, destChain.Timing()) + + balance, balErr := iftB.BalanceOf(ctx, receiver) + require.NoError(t, balErr) + require.Equal(t, 0, balance.Sign(), "receiver holds nothing while the recv is unmined") + + require.NoError(t, mining.Mine(ctx, 1)) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "completed packet records its destination effect tx") + + destAfter, afterErr := iftB.BalanceOf(ctx, receiver) + require.NoError(t, afterErr) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited once the recv is mined") + return nil + })) +} diff --git a/e2e/protocol/postgres_test.go b/e2e/protocol/postgres_test.go new file mode 100644 index 000000000..166f5ecd5 --- /dev/null +++ b/e2e/protocol/postgres_test.go @@ -0,0 +1,164 @@ +package protocol_test + +import ( + "context" + "fmt" + "math/big" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +const ( + postgresImage = "postgres:17-alpine" + postgresUser = "postgres" + postgresPassword = "postgres" + postgresDatabase = "relayer" + postgresReadyBudget = 90 * time.Second + postgresDockerLabel = "ibc-link-e2e=true" +) + +// TestPostgres_RealRelayerLane relays one real IFT transfer end to end with the +// relayer persisting to a real Postgres instead of the embedded sqlite. It +// starts a Postgres container, points the relayer's DB at it, and proves the +// transfer completes and /status is served from the Postgres-backed store. +func TestPostgres_RealRelayerLane(t *testing.T) { + dsn := startPostgres(t) + fx := newPostgresFixture(t, dsn) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + // A green completion polled from this relayer proves the Postgres-backed store + // serves /status throughout the packet's lifecycle. + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "completed packet is recorded in the Postgres store") + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited with the relayer backed by Postgres") +} + +// newPostgresFixture builds the standard single-Attestor-per-client IFT lane with +// the Relayer's persistence backend pointed at an external Postgres DSN. +func newPostgresFixture(t *testing.T, dsn string) fixture { + t.Helper() + spec, runtime, chainAEVMID := baseSpec(t) + auto := true + spec.Relayers[0].AutoRelay = &auto + spec.Relayers[0].PostgresURL = dsn + withStandardAttestors(&spec, runtime) + return fixture{ + suite: e2etest.SuiteFor(spec, runtime), + receiver: newAddress(t), + chainAEVMID: chainAEVMID, + } +} + +// startPostgres launches a labeled, pinned Postgres container reachable on +// loopback, waits until it accepts connections, and returns a DSN the relayer +// process can use. The container is force-removed on test cleanup. It shells out +// to the docker CLI (already a lane prerequisite) so the e2e module gains no new +// dependency. +func startPostgres(t *testing.T) string { + t.Helper() + + name := fmt.Sprintf("ibc-link-e2e-pg-%d-%d", os.Getpid(), time.Now().UnixNano()) + out, err := dockerOutput(t.Context(), + "run", "-d", + "--name", name, + "--label", postgresDockerLabel, + "-e", "POSTGRES_USER="+postgresUser, + "-e", "POSTGRES_PASSWORD="+postgresPassword, + "-e", "POSTGRES_DB="+postgresDatabase, + "-p", "127.0.0.1::5432", + postgresImage, + ) + require.NoError(t, err, "start postgres container: %s", out) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, rmErr := dockerOutput(ctx, "rm", "-f", name); rmErr != nil { + t.Logf("failed to remove postgres container %s: %v", name, rmErr) + } + }) + + port := postgresMappedPort(t, name) + waitPostgresReady(t, name) + return fmt.Sprintf( + "postgres://%s:%s@127.0.0.1:%s/%s?sslmode=disable", + postgresUser, postgresPassword, port, postgresDatabase, + ) +} + +// postgresMappedPort resolves the host loopback port docker assigned to the +// container's 5432/tcp. +func postgresMappedPort(t *testing.T, name string) string { + t.Helper() + out, err := dockerOutput(t.Context(), + "inspect", "--format", + `{{ (index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort }}`, + name, + ) + require.NoError(t, err, "inspect postgres port: %s", out) + port := strings.TrimSpace(string(out)) + require.NotEmpty(t, port, "postgres container reported no mapped port") + return port +} + +// waitPostgresReady polls pg_isready inside the container until it accepts +// connections or the budget elapses. +func waitPostgresReady(t *testing.T, name string) { + t.Helper() + deadline := time.Now().Add(postgresReadyBudget) + var lastErr error + var lastOut []byte + for time.Now().Before(deadline) { + lastOut, lastErr = dockerOutput(t.Context(), + "exec", name, "pg_isready", "-U", postgresUser, "-d", postgresDatabase, + ) + if lastErr == nil { + return + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("postgres container %s not ready within %s: %v: %s", name, postgresReadyBudget, lastErr, lastOut) +} + +func dockerOutput(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "docker", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return out, fmt.Errorf("docker %s: %w", strings.Join(args, " "), err) + } + return out, nil +} diff --git a/e2e/protocol/quorum_test.go b/e2e/protocol/quorum_test.go new file mode 100644 index 000000000..e9e467118 --- /dev/null +++ b/e2e/protocol/quorum_test.go @@ -0,0 +1,184 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// Quorum topology identities. An A->B transfer (route client-a) is proven by the +// DESTINATION client's attestor set — client-b's — so the three-of-which-two +// Attestors live on client-b; client-a keeps a single Attestor for the reverse +// route. Every Attestor signs with a distinct key (a graph-wide isolation +// invariant), and one extra unregistered key backs the wrong-key case. +const ( + quorumAttestorB1 = environment.AttestorID("quorum-attestor-b1") + quorumAttestorB2 = environment.AttestorID("quorum-attestor-b2") + quorumAttestorB3 = environment.AttestorID("quorum-attestor-b3") + quorumAttestorA = environment.AttestorID("quorum-attestor-a") + + quorumSignerB1 = environment.AuthorityID("quorum-signer-b1") + quorumSignerB2 = environment.AuthorityID("quorum-signer-b2") + quorumSignerB3 = environment.AuthorityID("quorum-signer-b3") + quorumSignerA = environment.AuthorityID("quorum-signer-a") + quorumWrongKey = environment.AuthorityID("quorum-wrong-key") +) + +const ( + quorumKeyB1 = "0000000000000000000000000000000000000000000000000000000000000001" + quorumKeyB2 = "0000000000000000000000000000000000000000000000000000000000000002" + quorumKeyB3 = "0000000000000000000000000000000000000000000000000000000000000004" + quorumKeyA = "0000000000000000000000000000000000000000000000000000000000000006" + quorumKeyWrong = "0000000000000000000000000000000000000000000000000000000000000007" +) + +// newQuorumFixture builds a truthful IFT lane whose A->B route is served by three +// Attestors (on the destination client-b) with a two-of-three threshold. When +// wrongKey is set, the FIRST-ordered Attestor's process signs with an +// unregistered key while its on-chain registration and the relayer's configured +// evmAddress stay the registered identity, forging the registered-vs-signing +// mismatch the attestor identity check must reject. +// +// The mis-keyed Attestor is deliberately b1, the first in the relayer's +// configured attestor order. Both the on-chain registration and the relayer's +// per-client attestor set are ordered by Attestor id (sortedAttestorSpecs), and +// the aggregator submits the first `threshold` valid-shape signatures in that +// configured order. Mis-keying the LAST member (b3) would let the two honest +// leading members (b1, b2) fill the two quorum slots even with identity +// enforcement disabled, so the old unenforced behavior would still pass. By +// mis-keying the FIRST member, the old behavior would place b1's unauthorized +// signature in a submitted slot and revert on-chain (UnknownSigner), while the +// fixed behavior drops b1 up front and forms quorum from the two honest trailing +// members (b2, b3) — so the test only passes because the fix is present. +func newQuorumFixture(t *testing.T, wrongKey bool) fixture { + t.Helper() + spec, runtime, chainAEVMID := baseSpec(t) + auto := true + spec.Relayers[0].AutoRelay = &auto + + // The A->B route is proven by the destination client-b's attestor set, so that + // is where the two-of-three quorum lives; client-a keeps a single Attestor for + // the reverse route. + spec.Connections[0].B = environment.NewClient{ + ID: clientB, IBCInstance: instanceB, Authority: deployer, MinRequiredSignatures: 2, + } + + attestorB1 := environment.AttestorSpec{ID: quorumAttestorB1, Client: clientB, Authority: quorumSignerB1} + if wrongKey { + attestorB1.SigningKeyAuthority = quorumWrongKey + } + spec.Attestors = []environment.AttestorSpec{ + attestorB1, + {ID: quorumAttestorB2, Client: clientB, Authority: quorumSignerB2}, + {ID: quorumAttestorB3, Client: clientB, Authority: quorumSignerB3}, + {ID: quorumAttestorA, Client: clientA, Authority: quorumSignerA}, + } + for id, key := range map[environment.AuthorityID]string{ + quorumSignerB1: quorumKeyB1, + quorumSignerB2: quorumKeyB2, + quorumSignerB3: quorumKeyB3, + quorumSignerA: quorumKeyA, + quorumWrongKey: quorumKeyWrong, + } { + runtime.Authorities[id] = environment.EVMAuthority{PrivateKeyHex: key} + } + + return fixture{ + suite: e2etest.SuiteFor(spec, runtime), + receiver: newAddress(t), + chainAEVMID: chainAEVMID, + } +} + +// TestQuorum_DegradedAttestor proves a two-of-three attestor set still forms +// quorum after one Attestor process is stopped: the A->B transfer completes on +// the two survivors. +func TestQuorum_DegradedAttestor(t *testing.T) { + fx := newQuorumFixture(t, false) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + // Drop one of the three destination-side Attestors before transferring; two + // remain, meeting the two-of-three threshold that gates the A->B route. + degraded, err := env.Attestor(quorumAttestorB3) + require.NoError(t, err) + require.NoError(t, degraded.Stop(ctx), "stop one of three Attestors") + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash) + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited via the two surviving attestors") +} + +// TestQuorum_WrongKeyAttestor is the e2e proof of the attestor identity check. +// Three Attestors are registered on the light client with a two-of-three +// threshold, but the FIRST-ordered one (b1) runs a key that does not match its +// registered address. Because the aggregator submits the first `threshold` +// valid-shape signatures in configured order, an unenforced relayer would place +// b1's unauthorized signature in a submitted slot and revert on-chain; the fixed +// relayer must drop b1's wrong-signer response up front and still form quorum +// from the two honest trailing signatures (b2, b3), completing the A->B transfer. +func TestQuorum_WrongKeyAttestor(t *testing.T) { + fx := newQuorumFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash) + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), + "receiver is credited: the wrong-key attestor is dropped and quorum forms from the two honest signers") +} diff --git a/e2e/protocol/resilience_test.go b/e2e/protocol/resilience_test.go new file mode 100644 index 000000000..8ce22182b --- /dev/null +++ b/e2e/protocol/resilience_test.go @@ -0,0 +1,376 @@ +package protocol_test + +import ( + "context" + "math/big" + "strconv" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// requireNodeUnreachable proves a stopped node no longer answers RPC. Stopping +// pauses the container, which keeps the port bound and HANGS connections rather +// than refusing them, so the probe must carry its own short deadline. +func requireNodeUnreachable(t *testing.T, chain *environment.Chain) { + t.Helper() + probeCtx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + _, err := chain.Height(probeCtx) + require.Error(t, err, "with the destination node stopped, an EVM height query must fail") +} + +// TestRestart_ManualRelayRecovery proves the real relayer resumes an unfinished +// packet across an abrupt crash without re-issuing the relay request. With +// auto-relay disabled, a manual POST /relay persists the packet as PENDING; the +// relayer is then SIGKILLed and relaunched against the SAME workspace and +// database. Its ListUnfinishedPackets must pick the persisted packet back up and +// drive it to completion, proving both crash recovery and manual-relay-request +// persistence across a restart. +// +// Recovery is only exercised if the packet is genuinely unfinished at the kill. +// A naive "POST /relay then immediately kill" races the engine, which can +// deliver before the SIGKILL lands and let the test pass without any restart +// recovery. To make the barrier deterministic we take the destination chain's +// node down before /relay: discovery is source-only, so /relay still persists the +// packet PENDING, but the engine cannot deliver to the stopped destination, so +// the packet is provably still pending at kill time. The node is brought back up +// while the relayer is dead, then the relayer is relaunched — its readiness +// requires the destination connected — and recovery must complete the packet +// purely from the persisted PENDING row. It requires environment-owned node +// lifecycle control (Managed Anvil) and skips on lanes that cannot stop and +// restart a node (Besu). +func TestRestart_ManualRelayRecovery(t *testing.T) { + fx := newFixture(t, false) + e2etest.RequireCapabilities(t, fx.suite, environment.Requirements{ + NodeLifecycle: []environment.ChainID{chainB}, + }) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(3_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + node, err := destChain.NodeLifecycle() + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + // Take the destination node down before registering the relay request. The + // send has already landed on chain A (still up); with the destination stopped + // the engine can discover and persist the packet but cannot deliver it. + require.NoError(t, node.Stop(ctx), "stop the destination node so the relay cannot complete") + requireNodeUnreachable(t, destChain) + + // POST /relay returns 200 only after the packet is registered and persisted as + // PENDING. Discovery reads only the source chain, so it succeeds while the + // destination is down. + result, err := relayer.Relay(ctx, wire.RelayRequest{ + SourceChainID: strconv.FormatUint(fx.chainAEVMID, 10), + SourceTxHash: transfer.SourceTxHash, + }) + require.NoError(t, err) + require.Contains(t, result.PacketIDs, id, "manual relay registers the transfer's packet") + + // The packet must be provably pending at the kill: the destination is down, so + // the engine cannot have delivered it. This is the barrier that guarantees the + // restart genuinely exercises recovery rather than racing a completion. + status, err := relayer.Status(ctx, wire.StatusQuery{PacketID: id}) + require.NoError(t, err) + pending, ok := status.Packet(id) + require.True(t, ok, "packet must be persisted after /relay") + require.Equal(t, wire.PacketPending, pending.State, + "packet must be pending (undeliverable while the destination is down) at kill time") + + require.NoError(t, relayer.Kill(), "SIGKILL the relayer while the packet is unfinished") + + // Bring the destination back up while the relayer is dead, so nothing can + // deliver the packet until the relayer is relaunched and recovers it. + require.NoError(t, node.Start(ctx)) + _, err = destChain.Height(ctx) + require.NoError(t, err, "after node restart the destination must be reachable again") + + // Relaunch against the same workspace/db. No new /relay is issued: recovery + // must come solely from the persisted PENDING packet. + require.NoError(t, relayer.Restart(ctx)) + + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "resumed packet records its destination effect tx") + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited after the relayer resumes post-restart") + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, sourceAfter.Sign(), "source balance stays burned across the restart") +} + +// Second-connection identities for the cross-route isolation test. A single +// relayer serves two independent Connections over the same two chains; each has +// its own Client pair and attestor set, so a fault on one cannot reach the other +// except through the shared relayer engine — which is exactly what the test +// probes. Every attestor signs with a distinct key (a graph-wide invariant). +const ( + connID2 = environment.ConnectionID("a-b-2") + clientA2 = environment.ClientID("client-a2") + clientB2 = environment.ClientID("client-b2") + attestorA2 = environment.AttestorID("attestor-a2") + attestorB2 = environment.AttestorID("attestor-b2") + xSignerA2 = environment.AuthorityID("cross-signer-a2") + xSignerB2 = environment.AuthorityID("cross-signer-b2") +) + +const ( + xSignerA2Key = "0000000000000000000000000000000000000000000000000000000000000004" + xSignerB2Key = "0000000000000000000000000000000000000000000000000000000000000006" +) + +// TestCrossRoute_IsolatedByAttestor proves that one `ibc relayer run` process +// serving two independent Connections keeps them isolated: breaking the recv-side +// attestor of one Connection's A->B route leaves that route's packet stuck +// pending (nothing delivered) while the other Connection's A->B route relays to +// completion. The stuck route neither blocks the healthy one nor reaches a +// terminal failure — in-process isolation, not mere process separation. +func TestCrossRoute_IsolatedByAttestor(t *testing.T) { + fx := newCrossRouteFixture(t) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + + require.NoError(t, env.AuthorizePublicRelay(ctx, instanceA, deployer)) + require.NoError(t, env.AuthorizePublicRelay(ctx, instanceB, deployer)) + + brokenConn, err := env.Connection(connID) + require.NoError(t, err) + healthyConn, err := env.Connection(connID2) + require.NoError(t, err) + // One IFT pair on the shared instances, bridged over BOTH Connections' clients. + // The two Connections carry independent Client pairs and attestor sets while + // sharing the ICS26 router (the relayer lists each chain once), so the same + // token can be sent over either route. + iftA, iftB := deployDualRouteIFT(t, env, brokenConn, healthyConn) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + healthyReceiver := environment.EVMAddress(fx.receiver.Hex()) + brokenReceiver := environment.EVMAddress(newAddress(t).Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, new(big.Int).Mul(amount, big.NewInt(2)))) + + // Stop the recv-side attestor of the broken Connection's A->B route (the + // attestor bound to its client-b, which attests chain A). Its A->B packet can + // no longer be received on chain B and stays pending with nothing delivered. + brokenAttestor, err := env.Attestor(attestorB) + require.NoError(t, err) + require.NoError(t, brokenAttestor.Stop(ctx), "stop the broken Connection's recv-side attestor") + + broken, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: brokenConn.A().Locator(), + Receiver: brokenReceiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + healthy, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: healthyConn.A().Locator(), + Receiver: healthyReceiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + // Each transfer is the first packet on its own client pair, so the two routes + // carry a colliding bare sequence number — the collision under test. + require.Equal(t, broken.Sequence, healthy.Sequence) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + healthyID := packetID(healthyConn.A(), healthy.Sequence) + brokenID := packetID(brokenConn.A(), broken.Sequence) + + // The healthy Connection completes even though the other Connection's route is + // stuck; the awaiter also proves /status keeps answering 200 for both routes + // while one is unfinished. + healthyPacket := awaitPacketState(t, env, healthyID, healthy.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, healthyPacket.EffectTxHash) + + // The broken route stays pending: not completed, not a terminal failure. + observePacketNeverTerminal(t, env, brokenID, destChain.Timing()) + + healthyBalance, err := iftB.BalanceOf(ctx, healthyReceiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(healthyBalance), "healthy Connection credits its receiver") + + brokenBalance, err := iftB.BalanceOf(ctx, brokenReceiver) + require.NoError(t, err) + require.Equal(t, 0, brokenBalance.Sign(), "broken Connection delivers nothing while its attestor is down") +} + +// newCrossRouteFixture builds two independent Connections over the same two +// chains and instances, each with its own Client pair and attestor set, served +// by a single Relayer process. +func newCrossRouteFixture(t *testing.T) fixture { + t.Helper() + spec, runtime, chainAEVMID := baseSpec(t) + auto := true + spec.Relayers[0].AutoRelay = &auto + // One Relayer process serves both Connections. + spec.Relayers[0].Connections = []environment.ConnectionID{connID, connID2} + + // A second independent Connection over the same chains and instances, with its + // own Client pair and attestor set. + spec.Connections = append(spec.Connections, environment.ConnectionSpec{ + ID: connID2, + A: environment.NewClient{ + ID: clientA2, IBCInstance: instanceA, Authority: deployer, MinRequiredSignatures: 1, + }, + B: environment.NewClient{ + ID: clientB2, IBCInstance: instanceB, Authority: deployer, MinRequiredSignatures: 1, + }, + }) + spec.Attestors = []environment.AttestorSpec{ + {ID: attestorA, Client: clientA, Authority: signerA}, + {ID: attestorB, Client: clientB, Authority: signerB}, + {ID: attestorA2, Client: clientA2, Authority: xSignerA2}, + {ID: attestorB2, Client: clientB2, Authority: xSignerB2}, + } + for id, key := range map[environment.AuthorityID]string{ + signerA: signerAKey, + signerB: signerBKey, + xSignerA2: xSignerA2Key, + xSignerB2: xSignerB2Key, + } { + runtime.Authorities[id] = environment.EVMAuthority{PrivateKeyHex: key} + } + + return fixture{ + suite: e2etest.SuiteFor(spec, runtime), + receiver: newAddress(t), + chainAEVMID: chainAEVMID, + } +} + +// deployDualRouteIFT deploys one IFT token pair on the shared instances and +// cross-registers their bridges over every given Connection's clients, so the +// same tokens can be sent over any of those Connections' routes. It assumes +// public relay is already authorized on both instances. A single GMP port exists +// per ICS26 router, so the pair is deployed once and reused across Connections. +func deployDualRouteIFT( + t *testing.T, + env *environment.Environment, + conns ...*environment.Connection, +) (iftA, iftB *environment.IFTApp) { + t.Helper() + ctx := t.Context() + + iftA, err := env.DeployIFT(ctx, environment.IFTRequest{ + Instance: instanceA, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, err) + iftB, err = env.DeployIFT(ctx, environment.IFTRequest{ + Instance: instanceB, Authority: deployer, TokenName: tokenName, TokenSymbol: tokenSymbol, + }) + require.NoError(t, err) + + for _, conn := range conns { + require.NoError(t, iftA.RegisterBridge(ctx, conn.A().Locator(), iftB.Address())) + require.NoError(t, iftB.RegisterBridge(ctx, conn.B().Locator(), iftA.Address())) + } + return iftA, iftB +} + +// TestRPCOutage_RelayerRecovers stops the destination chain's node mid-relay and +// restarts it: the real relayer must ride out the RPC outage and complete the +// packet once the node returns. It requires environment-owned node lifecycle +// control (Managed Anvil), and skips on lanes that cannot stop and restart a +// node (Besu). +func TestRPCOutage_RelayerRecovers(t *testing.T) { + fx := newFixture(t, true) + e2etest.RequireCapabilities(t, fx.suite, environment.Requirements{ + NodeLifecycle: []environment.ChainID{chainB}, + }) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_500_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + node, err := destChain.NodeLifecycle() + require.NoError(t, err) + + // Take the destination RPC down before sending. The send lands on chain A + // (still up); the relayer discovers the packet but cannot deliver to chain B. + require.NoError(t, node.Stop(ctx)) + requireNodeUnreachable(t, destChain) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + id := packetID(conn.A(), transfer.Sequence) + observePacketNeverTerminal(t, env, id, destChain.Timing()) + + require.NoError(t, node.Start(ctx)) + _, err = destChain.Height(ctx) + require.NoError(t, err, "after node restart the destination must be reachable again") + + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "packet completes once the destination RPC returns") + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited after the RPC outage clears") +} + +// newAddress returns a fresh random EVM address for a receiver that must be +// distinct from the fixture's default receiver. +func newAddress(t *testing.T) common.Address { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + return crypto.PubkeyToAddress(key.PublicKey) +} diff --git a/e2e/protocol/reverse_test.go b/e2e/protocol/reverse_test.go new file mode 100644 index 000000000..109d2a22c --- /dev/null +++ b/e2e/protocol/reverse_test.go @@ -0,0 +1,66 @@ +package protocol_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestIFT_RealRelayer_ReverseDirection proves the relayer relays the B->A +// direction end to end — recv submitted on chain A, ack written back on chain B — +// the mirror of every other protocol test, so a swapped reverse-route +// configuration cannot break B->A relaying invisibly. It asserts the receiver is +// credited on chain A and the sender's minted balance is burned on chain B. +func TestIFT_RealRelayer_ReverseDirection(t *testing.T) { + fx := newFixture(t, true) // auto-relay on: the relayer drives the reverse packet unaided. + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftB.Mint(ctx, deployerAddr, amount)) + + sourceBefore, err := iftB.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceBefore), "sender holds the minted balance on chain B before transfer") + destBefore, err := iftA.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, destBefore.Sign(), "receiver holds nothing on chain A before transfer") + + transfer, err := iftB.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.B().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + require.NotEmpty(t, transfer.SourceTxHash) + + // The destination for a B->A transfer is chain A: its Timing owns the budget. + destChain, err := env.Chain(chainA) + require.NoError(t, err) + id := packetID(conn.B(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketComplete, destChain.Timing()) + + require.NotEmpty(t, packet.EffectTxHash, "completed packet records its destination effect tx") + require.Equal(t, transfer.Sequence, packet.Sequence) + + sourceAfter, err := iftB.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, sourceAfter.Sign(), "source balance is burned on send from chain B") + destAfter, err := iftA.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "receiver is credited on chain A recv") +} diff --git a/e2e/protocol/status_test.go b/e2e/protocol/status_test.go new file mode 100644 index 000000000..5f09b1bc0 --- /dev/null +++ b/e2e/protocol/status_test.go @@ -0,0 +1,77 @@ +package protocol_test + +import ( + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestStatus_ViewsStayAvailable proves the relayer's three /status views — the +// unfiltered list-all, the route-filtered list, and the keyed single-packet +// lookup — all keep answering 200 across a packet's full lifecycle, including +// while it transitions. A single auto-relayed IFT transfer is driven to +// completion while every poll exercises all three views; any /status error fails +// the test. Keyed lifecycle assertions live in awaitPacketState; this test owns +// the availability of the list views so the shared awaiter can poll only the +// keyed view. +func TestStatus_ViewsStayAvailable(t *testing.T) { + fx := newFixture(t, true) + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(1_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: futureTimeout(), + }) + require.NoError(t, err) + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + + budget := destChain.Timing() + deadline := time.Now().Add(budget.CompletionBudget) + var completed bool + for time.Now().Before(deadline) { + // All three views must return 200 on every poll, including while the packet + // is mid-flight. + _, listErr := relayer.Status(ctx, wire.StatusQuery{}) + require.NoError(t, listErr, "unfiltered /status list-all must not error while a packet is present") + _, routeErr := relayer.Status(ctx, wire.StatusQuery{RouteID: routeIDOf(id)}) + require.NoError(t, routeErr, "route-filtered /status list must not error while a packet is present") + status, keyedErr := relayer.Status(ctx, wire.StatusQuery{PacketID: id}) + require.NoError(t, keyedErr, "keyed /status must not error while a packet is transitioning") + + if packet, ok := status.Packet(id); ok && packet.State == wire.PacketComplete { + completed = true + break + } + time.Sleep(budget.PollInterval) + } + require.True(t, completed, "packet %s did not complete within %s", id, budget.CompletionBudget) + + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(destAfter), "the transfer used to exercise the /status views still completes") +} diff --git a/e2e/protocol/timeout_cold_test.go b/e2e/protocol/timeout_cold_test.go new file mode 100644 index 000000000..7a51aac6f --- /dev/null +++ b/e2e/protocol/timeout_cold_test.go @@ -0,0 +1,83 @@ +package protocol_test + +import ( + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestTimeout_RealRelayer_ColdSendRefund proves the relayer's startup scan times +// out a packet that was sent while the relayer was down AND had already expired +// before the relayer came back: with no manual /relay, auto-relay alone must +// deliver the source-side timeoutPacket, refund the sender in full, and never +// credit the receiver. +func TestTimeout_RealRelayer_ColdSendRefund(t *testing.T) { + fx := newFixture(t, true) // auto-relay on: the startup scan alone must act. + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(3_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + sourceBefore, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceBefore), "sender holds the minted balance before transfer") + + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + require.NoError(t, relayer.Kill(), "kill the relayer before the send so the packet predates it") + + // The send must be MINED with block.timestamp < timeout, so the margin scales + // with the source block interval on self-mining lanes. + srcChain, err := env.Chain(chainA) + require.NoError(t, err) + margin := timeoutMargin + 5*srcChain.Timing().BlockInterval + timeout := uint64(time.Now().Add(margin).Unix()) + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: timeout, + }) + require.NoError(t, err) + require.NotEmpty(t, transfer.SourceTxHash) + + // The IFT burns the sender's balance on send; it is restored only if the packet + // is refunded. + burned, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, burned.Sign(), "IFT burns the sender balance on send") + + // Expire the packet while the relayer is still dead, so it is already dead on + // arrival when the relaunched relayer's startup scan finds it. + destChain, err := env.Chain(chainB) + require.NoError(t, err) + expireDestinationPastTimeout(t, destChain, timeout) + + require.NoError(t, relayer.Restart(ctx)) + + id := packetID(conn.A(), transfer.Sequence) + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketTimedOut, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "timed-out packet records its source-side timeout tx") + require.Equal(t, transfer.Sequence, packet.Sequence) + + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceAfter), "sender is refunded the full amount on timeout") + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, destAfter.Sign(), "receiver was never credited on the destination") +} diff --git a/e2e/protocol/timeout_test.go b/e2e/protocol/timeout_test.go new file mode 100644 index 000000000..d60e4f8c2 --- /dev/null +++ b/e2e/protocol/timeout_test.go @@ -0,0 +1,126 @@ +package protocol_test + +import ( + "math/big" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +const ( + // timeoutMargin keeps the transfer's timeout comfortably in the future when it + // is sent, so the source send is valid, while staying small enough that the + // destination clock can be pushed past it within the completion budget. The + // send tx must be MINED with block.timestamp < timeout, so on self-mining + // lanes the margin additionally scales with the block interval (a flat 4s + // reverts at send time on Besu's 2s blocks). + timeoutMargin = 4 * time.Second + // timeoutAdvance is how far past its current clock the destination is jumped on + // lanes that expose manual mining, so an attested header exists with a + // timestamp well beyond the transfer's timeout. + timeoutAdvance = 2 * time.Minute +) + +// TestTimeout_RealRelayer_Refund proves the real timeout-and-refund lifecycle: +// an IFT transfer is sent with a short timeout and auto-relay disabled, the +// destination clock is pushed past the timeout before any relay is requested, +// and the manual POST /relay then drives `ibc relayer run` to deliver a +// timeoutPacket on the SOURCE chain. It asserts the packet reaches the terminal +// timed_out state with the timeout tx as its effect, the sender's burned IFT +// balance is refunded on the source, and the receiver was never credited on the +// destination. +func TestTimeout_RealRelayer_Refund(t *testing.T) { + fx := newFixture(t, false) // auto-relay disabled: only the manual relay acts. + env := e2etest.Start(t, fx.suite) + ctx := t.Context() + + fundRelaySigner(t, env) + iftA, iftB, conn := deployBridgedIFT(t, env) + + deployerAddr, err := env.AuthorityAddress(deployer) + require.NoError(t, err) + receiver := environment.EVMAddress(fx.receiver.Hex()) + + amount := big.NewInt(3_000_000) + require.NoError(t, iftA.Mint(ctx, deployerAddr, amount)) + sourceBefore, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceBefore), "sender holds the minted balance before transfer") + + srcChain, err := env.Chain(chainA) + require.NoError(t, err) + margin := timeoutMargin + 5*srcChain.Timing().BlockInterval + timeout := uint64(time.Now().Add(margin).Unix()) + transfer, err := iftA.Transfer(ctx, environment.IFTTransferRequest{ + Sender: deployer, + SourceClient: conn.A().Locator(), + Receiver: receiver, + Amount: amount, + TimeoutTimestamp: timeout, + }) + require.NoError(t, err) + require.NotEmpty(t, transfer.SourceTxHash) + + // The IFT burns the sender's balance on send; it is restored only if the + // packet is refunded. + burned, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, burned.Sign(), "IFT burns the sender balance on send") + + destChain, err := env.Chain(chainB) + require.NoError(t, err) + expireDestinationPastTimeout(t, destChain, timeout) + + relayer, err := env.Relayer(relayerID) + require.NoError(t, err) + id := packetID(conn.A(), transfer.Sequence) + + result, err := relayer.Relay(ctx, wire.RelayRequest{ + SourceChainID: strconv.FormatUint(fx.chainAEVMID, 10), + SourceTxHash: transfer.SourceTxHash, + }) + require.NoError(t, err) + require.Contains(t, result.PacketIDs, id, "manual relay drives the timed-out transfer's packet") + + packet := awaitPacketState(t, env, id, transfer.SourceTxHash, wire.PacketTimedOut, destChain.Timing()) + require.NotEmpty(t, packet.EffectTxHash, "timed-out packet records its source-side timeout tx") + require.Equal(t, transfer.Sequence, packet.Sequence) + + sourceAfter, err := iftA.BalanceOf(ctx, deployerAddr) + require.NoError(t, err) + require.Equal(t, 0, amount.Cmp(sourceAfter), "sender is refunded the full amount on timeout") + destAfter, err := iftB.BalanceOf(ctx, receiver) + require.NoError(t, err) + require.Equal(t, 0, destAfter.Sign(), "receiver was never credited on the destination") +} + +// expireDestinationPastTimeout pushes the destination chain's clock beyond the +// transfer's timeout so the relayer can prove the packet timed out. On lanes that +// expose manual mining (instant Anvil) it jumps the clock deterministically; on +// self-mining lanes (interval Anvil, Besu) it waits in wall-clock for the chain +// to produce a block past the timeout. +func expireDestinationPastTimeout(t *testing.T, destChain *environment.Chain, timeout uint64) { + t.Helper() + ctx := t.Context() + + if mining, err := destChain.Mining(); err == nil { + require.NoError(t, mining.AdvanceTime(ctx, timeoutAdvance)) + require.NoError(t, mining.Mine(ctx, 2)) + return + } + + block := destChain.Timing().BlockInterval + if block <= 0 { + block = time.Second + } + deadline := time.Unix(int64(timeout), 0).Add(3 * block) + for time.Now().Before(deadline) { + time.Sleep(block) + } +} diff --git a/e2e/scripts/clean.sh b/e2e/scripts/clean.sh new file mode 100755 index 000000000..02a37df9e --- /dev/null +++ b/e2e/scripts/clean.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +dry_run=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) dry_run=1 ;; + -h|--help) + printf 'Usage: %s [--dry-run]\n\n' "$0" + printf 'Stops leaked e2e relayer and attestor daemons and removes e2e Docker containers by label.\n' + printf 'Daemons are matched by the harness workspace marker (an ibc-environment-private-* --home in argv),\n' + printf 'so a developer'"'"'s own unrelated "ibc relayer run" or "ibc attestor run" is left alone.\n' + exit 0 + ;; + *) + printf 'clean-e2e: unknown argument %s\n' "$1" >&2 + exit 2 + ;; + esac + shift +done + +note() { printf 'clean-e2e: %s\n' "$*"; } + +sweep() { + local label="$1" pattern="$2" pids + pids="$(pgrep -f "$pattern" || true)" + if [ -z "$pids" ]; then + note "no $label found" + return + fi + note "stop $label:" + # shellcheck disable=SC2086 + ps -o pid=,command= -p $pids || true + if [ "$dry_run" -eq 1 ]; then + return + fi + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + sleep 1 + # SIGKILL the original capture only — do not re-pgrep. + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true +} + +# Match the real `ibc` binary running `relayer run` or `attestor run`, scoped to a harness-owned +# workspace (--home under an ibc-environment-private-* temp dir, always present on a harness-spawned +# daemon) so a developer's own unrelated `ibc … run` is never signaled. Binary is anchored at a path +# separator or start of the cmdline so an unrelated `…ibc` suffix can't false-match. +sweep "e2e relayer daemons" '(^|/)ibc relayer run .*ibc-environment-private-' +sweep "e2e attestor daemons" '(^|/)ibc attestor run .*ibc-environment-private-' + +docker_sweep() { + if ! command -v docker >/dev/null; then + note "docker not found; skip Docker resources" + return + fi + if ! docker info >/dev/null 2>&1; then + note "docker not reachable; skip Docker resources" + return + fi + + local containers + containers="$(docker ps -aq --filter label=ibc-link-e2e=true 2>/dev/null)" + if [ -z "$containers" ]; then + note "no e2e Docker containers found" + else + note "remove e2e Docker containers:" + for id in $containers; do + docker ps -a --format ' {{.ID}} {{.Names}} {{.Status}}' --filter "id=$id" || true + done + if [ "$dry_run" -eq 0 ]; then + # shellcheck disable=SC2086 + docker rm -f $containers >/dev/null 2>&1 || true + fi + fi +} + +docker_sweep + +note "done" diff --git a/e2e/scripts/generate-contract-bindings.sh b/e2e/scripts/generate-contract-bindings.sh new file mode 100755 index 000000000..a4c08c40d --- /dev/null +++ b/e2e/scripts/generate-contract-bindings.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH=''; cd -- "$(dirname -- "$0")/../.." && pwd) +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +generate() { + artifact=$1 + package=$2 + type_name=$3 + output=$4 + abi_file="$tmp_dir/$type_name.abi" + bin_file="$tmp_dir/$type_name.bin" + + jq -c '.abi' "$artifact" > "$abi_file" + jq -r '.bytecode.object' "$artifact" > "$bin_file" + abigen --abi "$abi_file" --bin "$bin_file" --pkg "$package" --type "$type_name" --out "$output" +} + +solidity_ibc="$repo_root/e2e/internal/harness/environment/solidityibc" +access_manager="$solidity_ibc/accessmanager" +mkdir -p "$access_manager" +generate "$solidity_ibc/contracts/out/AccessManager.sol/AccessManager.json" accessmanager AccessManager "$access_manager/contract.go" diff --git a/e2e/setup/deploy_test.go b/e2e/setup/deploy_test.go new file mode 100644 index 000000000..fb7eec1cb --- /dev/null +++ b/e2e/setup/deploy_test.go @@ -0,0 +1,335 @@ +package setup_test + +import ( + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ift" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/environment" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// The two config-level client aliases and their on-chain custom client IDs. The +// aliases name the deploy/connect target; the IDs are valid Solidity IBC custom +// client identifiers (not "client-"/"channel-" generated names). +const ( + aliasAToB = "a-to-b" + aliasBToA = "b-to-a" + clientIDA = "chainb-0" // the client on chain A points at chain B + clientIDB = "chaina-0" // the client on chain B points at chain A + deployAlias = "deployer" + attestorA = "0x1111111111111111111111111111111111111111" + attestorB = "0x2222222222222222222222222222222222222222" +) + +// deployFundingMinimum is generous relative to the handful of contract-deployment +// transactions `ibc connect` submits per chain. +var deployFundingMinimum = new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)) + +// deployResult mirrors the JSON document `ibc deploy`/`ibc connect` print to +// stdout (link/internal/deploy.Result). It is hand-rolled, not imported, so the +// black-box e2e module never depends on link/internal. +type deployResult struct { + Mode string `json:"mode"` + DryRun bool `json:"dryRun"` + Chains []deployChainResult `json:"chains"` + Plan []deployPlanStep `json:"plan"` +} + +type deployChainResult struct { + ChainID string `json:"chainId"` + AccessManager string `json:"accessManager"` + ICS26Router string `json:"ics26Router"` + Client *deployClientRes `json:"client"` + GMP string `json:"gmp"` + IFT string `json:"ift"` + SendCallConstructor string `json:"sendCallConstructor"` + Skipped []string `json:"skipped"` +} + +type deployClientRes struct { + ClientID string `json:"clientId"` + Address string `json:"address"` +} + +type deployPlanStep struct { + Action string `json:"action"` +} + +func TestDeployConnect_RealCLI(t *testing.T) { + env := e2etest.Start(t, e2etest.SelectedSuite(t)) + ctx := t.Context() + + chainA, err := env.Chain(e2etest.ChainA) + require.NoError(t, err) + chainB, err := env.Chain(e2etest.ChainB) + require.NoError(t, err) + + chainAID := strconv.FormatUint(chainA.EVMChainID(), 10) + chainBID := strconv.FormatUint(chainB.EVMChainID(), 10) + + home := t.TempDir() + keyPath := filepath.Join(home, "keys", deployAlias+".json") + + writeDeployConfig(t, home, deployConfig{ + DBURL: filepath.Join(home, "deploy.db"), + ChainAID: chainAID, + RPCA: chainA.RPCURL(), + ChainBID: chainBID, + RPCB: chainB.RPCURL(), + KeyPath: keyPath, + AttestorA: attestorA, + AttestorB: attestorB, + }) + + newKey := runIBC(t, home, "keys", "new", "ecdsa", deployAlias) + require.Equal(t, wire.ExitOK, newKey.exitCode, "keys new stderr: %s", newKey.stderr) + + show := runIBC(t, home, "keys", "show", deployAlias) + require.Equal(t, wire.ExitOK, show.exitCode, "keys show stderr: %s", show.stderr) + var shown struct { + EVMAddress string `json:"evmAddress"` + } + require.NoError(t, json.Unmarshal([]byte(show.stdout), &shown)) + require.NotEmpty(t, shown.EVMAddress, "keys show must report the ecdsa key's evmAddress") + deployerAddr := common.HexToAddress(shown.EVMAddress) + + perChainDeployActions := []string{ + "deploy-instance", + "deploy-gmp", + "deploy-ift", + "deploy-sendcall", + "deploy-client", + "authorize-public-relay", + } + + t.Run("deploy_dry_run_plans_full_graph_without_bridge_wiring", func(t *testing.T) { + out := runIBC(t, home, "deploy", aliasAToB, "--dry-run") + require.Equal(t, wire.ExitOK, out.exitCode, "deploy --dry-run stderr: %s", out.stderr) + result := decodeDeployResult(t, out.stdout, out.stderr) + require.Equal(t, "deploy", result.Mode) + require.True(t, result.DryRun) + + var expected []string + expected = append(expected, perChainDeployActions...) + expected = append(expected, perChainDeployActions...) + require.ElementsMatch(t, expected, planActions(result.Plan), + "deploy must plan the full per-chain graph on both chains and no bridge wiring") + }) + + t.Run("connect_dry_run_plans_full_graph_with_bridge_wiring", func(t *testing.T) { + out := runIBC(t, home, "connect", aliasAToB, "--dry-run") + require.Equal(t, wire.ExitOK, out.exitCode, "connect --dry-run stderr: %s", out.stderr) + result := decodeDeployResult(t, out.stdout, out.stderr) + require.Equal(t, "connect", result.Mode) + require.True(t, result.DryRun) + + var expected []string + expected = append(expected, perChainDeployActions...) + expected = append(expected, perChainDeployActions...) + expected = append(expected, "register-ift-bridge", "register-ift-bridge") + require.ElementsMatch(t, expected, planActions(result.Plan), + "connect must plan the full deploy graph plus the IFT bridge wiring on both chains") + }) + + for _, chain := range []*environment.Chain{chainA, chainB} { + funding, fundErr := chain.Funding() + require.NoError(t, fundErr) + require.NoError(t, funding.EnsureEOABalance(ctx, deployerAddr, deployFundingMinimum)) + } + + t.Run("connect_deploys_real_contracts_on_chain", func(t *testing.T) { + out := runIBC(t, home, "connect", aliasAToB) + require.Equalf(t, wire.ExitOK, out.exitCode, + "connect must succeed; stdout=%s stderr=%s", out.stdout, out.stderr) + + result := decodeDeployResult(t, out.stdout, out.stderr) + require.Equal(t, "connect", result.Mode) + require.False(t, result.DryRun) + require.Len(t, result.Chains, 2, "connect reports both chains") + + byChain := map[string]deployChainResult{} + for _, cr := range result.Chains { + byChain[cr.ChainID] = cr + } + + for _, tc := range []struct { + chainID string + chain *environment.Chain + clientID string + counterpartyClientID string + counterpartyChainID string + }{ + {chainAID, chainA, clientIDA, clientIDB, chainBID}, + {chainBID, chainB, clientIDB, clientIDA, chainAID}, + } { + cr, ok := byChain[tc.chainID] + require.Truef(t, ok, "connect result missing chain %s", tc.chainID) + + require.NotEmpty(t, cr.AccessManager, "chain %s AccessManager", tc.chainID) + require.NotEmpty(t, cr.ICS26Router, "chain %s ICS26Router", tc.chainID) + require.NotEmpty(t, cr.GMP, "chain %s GMP", tc.chainID) + require.NotEmpty(t, cr.IFT, "chain %s IFT", tc.chainID) + require.NotEmpty(t, cr.SendCallConstructor, "chain %s SendCallConstructor", tc.chainID) + require.Empty(t, cr.Skipped, "chain %s should deploy everything fresh, skipped=%v", tc.chainID, cr.Skipped) + require.NotNil(t, cr.Client, "chain %s client", tc.chainID) + require.Equal(t, tc.clientID, cr.Client.ClientID, "chain %s client id", tc.chainID) + require.NotEmpty(t, cr.Client.Address, "chain %s client address", tc.chainID) + + // The JSON is only a claim; confirm each address carries deployed bytecode + // on the real node the CLI targeted, read through the harness chain client. + evm, evmErr := tc.chain.EVM() + require.NoError(t, evmErr) + for label, addr := range map[string]string{ + "AccessManager": cr.AccessManager, + "ICS26Router": cr.ICS26Router, + "GMP": cr.GMP, + "IFT": cr.IFT, + "SendCallConstructor": cr.SendCallConstructor, + "Client": cr.Client.Address, + } { + code, codeErr := evm.CodeAt(ctx, common.HexToAddress(addr), nil) + require.NoErrorf(t, codeErr, "chain %s read code at %s (%s)", tc.chainID, label, addr) + require.NotEmptyf(t, code, "chain %s %s (%s) has no deployed code", tc.chainID, label, addr) + } + + // Bytecode presence only proves the addresses hold code. Read the actual + // wiring back through the typed contract bindings (mirroring the reference + // on-chain assertions in link/internal/deploy) to prove client + // registration, counterparty pairing, and IFT-bridge cross-wiring really + // happened on the node the CLI targeted. + counterpartyIFT := common.HexToAddress(byChain[tc.counterpartyChainID].IFT) + var ( + routerAuthority common.Address + registeredClient common.Address + counterpartyClientID string + bridgeCounterpartyIFT common.Address + bridgeSendCall common.Address + ) + require.NoErrorf(t, evm.UseContractCaller(func(caller bind.ContractCaller) error { + router, bindErr := ics26router.NewContractCaller(common.HexToAddress(cr.ICS26Router), caller) + if bindErr != nil { + return bindErr + } + opts := &bind.CallOpts{Context: ctx} + var callErr error + if routerAuthority, callErr = router.Authority(opts); callErr != nil { + return callErr + } + if registeredClient, callErr = router.GetClient(opts, tc.clientID); callErr != nil { + return callErr + } + counterparty, cpErr := router.GetCounterparty(opts, tc.clientID) + if cpErr != nil { + return cpErr + } + counterpartyClientID = counterparty.ClientId + + iftContract, iftErr := ift.NewContractCaller(common.HexToAddress(cr.IFT), caller) + if iftErr != nil { + return iftErr + } + bridge, bridgeErr := iftContract.GetIFTBridge(opts, tc.clientID) + if bridgeErr != nil { + return bridgeErr + } + bridgeCounterpartyIFT = common.HexToAddress(bridge.CounterpartyIFTAddress) + bridgeSendCall = bridge.IftSendCallConstructor + return nil + }), "chain %s read IBC wiring through contract bindings", tc.chainID) + + require.Equalf(t, common.HexToAddress(cr.AccessManager), routerAuthority, + "chain %s router authority must be the reported AccessManager", tc.chainID) + require.Equalf(t, common.HexToAddress(cr.Client.Address), registeredClient, + "chain %s client %q must resolve to the reported client address", tc.chainID, tc.clientID) + require.Equalf( + t, + tc.counterpartyClientID, + counterpartyClientID, + "chain %s client %q must be paired with counterparty %q", + tc.chainID, + tc.clientID, + tc.counterpartyClientID, + ) + require.Equalf(t, counterpartyIFT, bridgeCounterpartyIFT, + "chain %s IFT bridge for %q must point at the counterparty IFT", tc.chainID, tc.clientID) + require.Equalf(t, common.HexToAddress(cr.SendCallConstructor), bridgeSendCall, + "chain %s IFT bridge for %q must carry the reported send-call constructor", tc.chainID, tc.clientID) + } + }) +} + +// decodeDeployResult decodes the deploy/connect stdout JSON, failing loudly (with +// captured streams) when the command did not emit a parseable result document. +func decodeDeployResult(t *testing.T, stdout, stderr string) deployResult { + t.Helper() + var result deployResult + require.NoErrorf(t, json.Unmarshal([]byte(stdout), &result), + "deploy/connect must print a Result JSON to stdout; got stdout=%q stderr=%q", stdout, stderr) + return result +} + +func planActions(plan []deployPlanStep) []string { + actions := make([]string, 0, len(plan)) + for _, step := range plan { + actions = append(actions, step.Action) + } + return actions +} + +// deployConfig is the hand-rolled subset of the real config schema the deploy +// cases need: two EVM chains, a local deployer signer, and both relayer clients +// with remote attestor sets carrying explicit on-chain addresses. +type deployConfig struct { + DBURL string + ChainAID string + RPCA string + ChainBID string + RPCB string + KeyPath string + AttestorA string + AttestorB string +} + +func writeDeployConfig(t *testing.T, home string, cfg deployConfig) { + t.Helper() + var b strings.Builder + b.WriteString("server:\n listenAddr: \"127.0.0.1:3000\"\n") + fmt.Fprintf(&b, "db:\n type: %s\n url: %q\n", sqliteDBType, cfg.DBURL) + b.WriteString("chains:\n") + fmt.Fprintf(&b, " - chainId: %q\n evm:\n rpc: %q\n", cfg.ChainAID, cfg.RPCA) + fmt.Fprintf(&b, " - chainId: %q\n evm:\n rpc: %q\n", cfg.ChainBID, cfg.RPCB) + fmt.Fprintf(&b, "signers:\n - alias: %s\n type: local\n file: %q\n", deployAlias, cfg.KeyPath) + fmt.Fprintf(&b, "relayer:\n signer: %s\n clients:\n", deployAlias) + writeDeployClient(&b, aliasAToB, clientIDA, cfg.ChainAID, cfg.ChainBID, clientIDB, "att-a", cfg.AttestorA) + writeDeployClient(&b, aliasBToA, clientIDB, cfg.ChainBID, cfg.ChainAID, clientIDA, "att-b", cfg.AttestorB) + + require.NoError(t, os.WriteFile(filepath.Join(home, configName), []byte(b.String()), 0o600)) +} + +func writeDeployClient( + b *strings.Builder, + alias, clientID, chainID, counterpartyChainID, counterpartyClientID, attestorName, attestorAddr string, +) { + fmt.Fprintf(b, " - alias: %s\n", alias) + fmt.Fprintf(b, " clientId: %q\n", clientID) + fmt.Fprintf(b, " chainId: %q\n", chainID) + fmt.Fprintf(b, " counterpartyChainId: %q\n", counterpartyChainID) + fmt.Fprintf(b, " counterpartyClientId: %q\n", counterpartyClientID) + b.WriteString(" type: attestation\n") + b.WriteString(" attestorSet:\n threshold: 1\n attestors:\n") + fmt.Fprintf(b, " - name: %s\n type: remote\n grpc: \"127.0.0.1:1\"\n", attestorName) + fmt.Fprintf(b, " evmAddress: %q\n", attestorAddr) +} diff --git a/e2e/setup/helpers_test.go b/e2e/setup/helpers_test.go new file mode 100644 index 000000000..34e43ca4e --- /dev/null +++ b/e2e/setup/helpers_test.go @@ -0,0 +1,133 @@ +package setup_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/internal/harness/ibclink" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +type ibcOutcome struct { + stdout string + stderr string + exitCode int +} + +func runIBC(t *testing.T, home string, args ...string) ibcOutcome { + t.Helper() + full := append([]string{"--home", home, "--config", configName}, args...) + cmd := exec.CommandContext(t.Context(), ibclink.ResolvedBin(), full...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + exitCode := wire.ExitOK + if runErr := cmd.Run(); runErr != nil { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + t.Fatalf("run ibc %v: %v (stderr: %s)", args, runErr, stderr.String()) + } + exitCode = exitErr.ExitCode() + } + return ibcOutcome{stdout: stdout.String(), stderr: stderr.String(), exitCode: exitCode} +} + +const ( + configName = "ibc.yml" + listenAddr = "127.0.0.1:3000" + sqliteDBType = "sqlite" +) + +// validateConfig is the hand-rolled subset of the real `ibc` config schema the +// validate cases exercise. It is written as YAML text (not marshaled from an +// imported product type) so the black-box e2e module never depends on +// link/internal/config. +type validateConfig struct { + ListenAddr string + DBType string + DBURL string + Chains []validateChain +} + +type validateChain struct { + ChainID string + RPC string + ICS26Router string +} + +func writeRealConfig(t *testing.T, home string, cfg validateConfig) { + t.Helper() + var b strings.Builder + fmt.Fprintf(&b, "server:\n listenAddr: %q\n", cfg.ListenAddr) + fmt.Fprintf(&b, "db:\n type: %s\n url: %q\n", cfg.DBType, cfg.DBURL) + b.WriteString("chains:\n") + for _, chain := range cfg.Chains { + fmt.Fprintf(&b, " - chainId: %q\n evm:\n rpc: %q\n", chain.ChainID, chain.RPC) + if chain.ICS26Router != "" { + fmt.Fprintf(&b, " ics26Router: %q\n", chain.ICS26Router) + } + } + require.NoError(t, os.WriteFile(filepath.Join(home, configName), []byte(b.String()), 0o600)) +} + +type validateOutcome struct { + result wire.ValidateResult + exitCode int +} + +func runValidate(t *testing.T, home string, live bool) validateOutcome { + t.Helper() + args := []string{"config", "validate"} + if live { + args = append(args, "--live") + } + out := runIBC(t, home, args...) + + var result wire.ValidateResult + require.NoErrorf(t, json.Unmarshal([]byte(out.stdout), &result), + "config validate must print a ValidateResult JSON to stdout; got stdout=%q stderr=%q", + out.stdout, out.stderr) + return validateOutcome{result: result, exitCode: out.exitCode} +} + +func hasErrorPath(errs []wire.ValidationIssue, path string) bool { + for _, e := range errs { + if e.Path == path { + return true + } + } + return false +} + +// refusingRPCURL returns an RPC URL whose port accepts a connection and then +// immediately closes it, so a live probe fails as unreachable without depending +// on a free-port race. +func refusingRPCURL(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = l.Close() }) + + go func() { + for { + conn, acceptErr := l.Accept() + if acceptErr != nil { + return + } + _ = conn.Close() + } + }() + + return "http://" + l.Addr().String() +} diff --git a/e2e/setup/validate_test.go b/e2e/setup/validate_test.go new file mode 100644 index 000000000..342bfa06f --- /dev/null +++ b/e2e/setup/validate_test.go @@ -0,0 +1,110 @@ +package setup_test + +import ( + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/e2e/e2etest" + "github.com/cosmos/ibc/e2e/internal/harness/ibclink/wire" +) + +// TestConfigValidate_RealSchema drives the real `ibc config validate` against +// hand-crafted configs in the product's own config schema and asserts its +// machine-readable contract: a ValidateResult JSON on stdout plus a sysexits exit +// code. The chains point at the selected suite's live nodes (whose RPC URLs the +// environment exposes directly) so --live has something real to probe. +func TestConfigValidate_RealSchema(t *testing.T) { + env := e2etest.Start(t, e2etest.SelectedSuite(t)) + a, err := env.Chain(e2etest.ChainA) + require.NoError(t, err) + b, err := env.Chain(e2etest.ChainB) + require.NoError(t, err) + + chainAID := strconv.FormatUint(a.EVMChainID(), 10) + chainBID := strconv.FormatUint(b.EVMChainID(), 10) + + t.Run("valid_live_exit0", func(t *testing.T) { + home := t.TempDir() + writeRealConfig(t, home, validateConfig{ + ListenAddr: listenAddr, + DBType: sqliteDBType, + DBURL: filepath.Join(home, "valid.db"), + Chains: []validateChain{ + {ChainID: chainAID, RPC: a.RPCURL()}, + {ChainID: chainBID, RPC: b.RPCURL()}, + }, + }) + + out := runValidate(t, home, true) + require.Equal(t, wire.ExitOK, out.exitCode) + require.True(t, out.result.StructurallyValid) + require.Empty(t, out.result.Errors) + require.Contains(t, out.result.ResolvedChains, chainAID) + require.Contains(t, out.result.ResolvedChains, chainBID) + }) + + t.Run("structurally_invalid_exit64", func(t *testing.T) { + home := t.TempDir() + writeRealConfig(t, home, validateConfig{ + ListenAddr: listenAddr, + DBType: sqliteDBType, + DBURL: filepath.Join(home, "bad.db"), + Chains: []validateChain{ + {ChainID: chainAID, RPC: a.RPCURL(), ICS26Router: "not-an-address"}, + }, + }) + + out := runValidate(t, home, false) + require.Equal(t, wire.ExitConfigInvalid, out.exitCode) + require.False(t, out.result.StructurallyValid) + require.Truef(t, hasErrorPath(out.result.Errors, "chains[0].evm.ics26Router"), + "expected a located structural error, got %+v", out.result.Errors) + }) + + t.Run("live_unreachable_rpc_exit65", func(t *testing.T) { + home := t.TempDir() + writeRealConfig(t, home, validateConfig{ + ListenAddr: listenAddr, + DBType: sqliteDBType, + DBURL: filepath.Join(home, "down.db"), + Chains: []validateChain{ + {ChainID: chainAID, RPC: refusingRPCURL(t)}, + }, + }) + + structural := runValidate(t, home, false) + require.Equal(t, wire.ExitOK, structural.exitCode) + require.True(t, structural.result.StructurallyValid) + + out := runValidate(t, home, true) + require.Equal(t, wire.ExitRPCUnreachable, out.exitCode) + require.True(t, out.result.StructurallyValid, "a live RPC failure preserves structural validity") + require.Truef(t, hasErrorPath(out.result.Errors, "chains[0].evm.rpc"), + "expected a located live RPC error, got %+v", out.result.Errors) + }) + + t.Run("live_chainid_mismatch_exit65", func(t *testing.T) { + home := t.TempDir() + writeRealConfig(t, home, validateConfig{ + ListenAddr: listenAddr, + DBType: sqliteDBType, + DBURL: filepath.Join(home, "mismatch.db"), + Chains: []validateChain{ + {ChainID: strconv.FormatUint(a.EVMChainID()+99999, 10), RPC: a.RPCURL()}, + }, + }) + + structural := runValidate(t, home, false) + require.Equal(t, wire.ExitOK, structural.exitCode) + require.True(t, structural.result.StructurallyValid) + + out := runValidate(t, home, true) + require.Equal(t, wire.ExitRPCUnreachable, out.exitCode) + require.True(t, out.result.StructurallyValid, "a live chain-id mismatch preserves structural validity") + require.Truef(t, hasErrorPath(out.result.Errors, "chains[0].chainId"), + "expected a located chain-id mismatch error, got %+v", out.result.Errors) + }) +} diff --git a/link/.gitignore b/link/.gitignore index 504f1d07f..ae1296eb7 100644 --- a/link/.gitignore +++ b/link/.gitignore @@ -1,4 +1,7 @@ bin/ +# stray `go build` output at the module root (sanctioned output dir is bin/) +/ibc + go.work go.work.sum \ No newline at end of file diff --git a/link/.mockery.yaml b/link/.mockery.yaml index 2758995f7..6ec56169b 100644 --- a/link/.mockery.yaml +++ b/link/.mockery.yaml @@ -11,15 +11,15 @@ packages: structname: "Mock{{.InterfaceName}}" interfaces: AttestationServiceClient: - github.com/cosmos/ibc/link/internal/types/v2/relayer: + github.com/cosmos/ibc/link/internal/service/relayer: config: - include-auto-generated: true dir: "{{.InterfaceDir}}" - filename: "relayer.mock.go" + filename: "service.mock.go" pkgname: "{{.SrcPackageName}}" structname: "Mock{{.InterfaceName}}" interfaces: - RelayerApiServiceClient: + Store: + ChainClientManager: github.com/cosmos/ibc/link/internal/store: config: dir: "{{.InterfaceDir}}" @@ -28,6 +28,22 @@ packages: structname: "Mock{{.InterfaceName}}" interfaces: Repository: + github.com/cosmos/ibc/link/internal/chains: + config: + dir: "{{.InterfaceDir}}" + filename: "chains.mock.go" + pkgname: "{{.SrcPackageName}}" + structname: "Mock{{.InterfaceName}}" + interfaces: + Client: + github.com/cosmos/ibc/link/internal/chains/evm: + config: + dir: "{{.InterfaceDir}}" + filename: "evm.mock.go" + pkgname: "{{.SrcPackageName}}" + structname: "Mock{{.InterfaceName}}" + interfaces: + ETHClient: github.com/cosmos/kms/gen/signerservice: config: include-auto-generated: true diff --git a/link/AGENTS.md b/link/AGENTS.md index d9b6183b6..f4b7c2961 100644 --- a/link/AGENTS.md +++ b/link/AGENTS.md @@ -1,4 +1,9 @@ # IBC Link Development Guide for AI Agents - Use `make lint-fix` to auto-format and lint code before finishing work. -- Don't add verbose comments. Be concise. \ No newline at end of file +- Don't add verbose comments. Be concise. +- Repository-wide black-box e2e lives in `../e2e`, with its harness in + `../e2e/internal/harness` as a separate Go module. It runs the real `ibc` binary + (`../e2e/internal/harness/ibclink.ResolvedBin`, overridable via `IBC_BIN`) against real + contracts and asserts the wire contract in `../e2e/internal/harness/ibclink/wire`. Changes to that + wire surface must keep `make test-e2e` from the repository root green. diff --git a/link/CLAUDE.md b/link/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/link/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/link/CONTEXT.md b/link/CONTEXT.md new file mode 100644 index 000000000..f6b63e288 --- /dev/null +++ b/link/CONTEXT.md @@ -0,0 +1,43 @@ +# IBC Link Environment + +The IBC Link environment context describes the on-chain protocol topology and off-chain actors that a test declares and obtains as a ready Environment. + +## Language + +**Environment Spec**: +A declaration of the Chains, IBC Instances, IBC Connections, Attestors, and Relayers a test requires. It describes desired resources rather than their ready state. + +**Environment**: +The realized, ready set of resources declared by an Environment Spec. It is the lifecycle boundary through which a test accesses those resources. + +**Realization**: +The process by which an Environment Spec becomes an Environment, satisfying each declaration with a newly created or already-existing resource. + +**Chain**: +A blockchain network that hosts zero or more IBC Instances. + +**IBC Instance**: +An installation of the IBC protocol hosted on a Chain. An IBC Instance may be newly created or already exist. +_Avoid_: Deployment + +**IBC Client**: +One end of an IBC Connection, hosted by an IBC Instance and tracking the counterparty. Its counterparty client identifier points to the reciprocal IBC Client. + +**IBC Connection**: +The IBC protocol relationship between two IBC Instances, constituted by a reciprocal pair of IBC Clients. +_Avoid_: Route as a synonym for an IBC Connection + +**Attestor**: +An independent logical actor configured to serve a particular IBC Client. Its identity is independent of process or endpoint placement. + +**Relayer**: +An independent logical actor that relays protocol messages for one or more IBC Connections. Its identity is independent of process or endpoint placement, and multiple Relayers may serve the same IBC Connection. + +**Relayer Route**: +A directed configuration belonging to a Relayer when it needs to carry messages in one direction over an IBC Connection. It is not an independent Environment resource and does not replace the Connection. + +**Test Application**: +An application deployed by a test to originate protocol traffic or expose observable state (the real IFT and GMP contracts). It is explicit test setup, not an Environment resource. + +**Test Application Binding**: +A typed, non-owning test-side handle to one application's contracts deployed on one IBC Instance. It hides application ABI and RPC mechanics but does not own lifecycle, relayer state, or cross-resource orchestration. diff --git a/link/HARNESS-ARCHITECTURE-DESIGN.md b/link/HARNESS-ARCHITECTURE-DESIGN.md new file mode 100644 index 000000000..cdf3a3a01 --- /dev/null +++ b/link/HARNESS-ARCHITECTURE-DESIGN.md @@ -0,0 +1,146 @@ +# IBC Environment Architecture + +Status: accepted and implemented. Real relayer and attestor processes carry all protocol traffic; the transitional synthetic acceptance path no longer exists. + +## Decision + +The harness has one deep `environment` module. Callers declare the IBC environment they need, `environment.Start` privately realizes that declaration, and the returned `Environment` is the sole owner of every declared resource and local handle. + +Tests keep their behavioral orchestration explicit. There is no application registry, test-application resource layer, generic scenario framework, or second `Spec`/`Runtime`/`Start`/`Close` stack. + +```text +environment.Spec + environment.Runtime + │ + ▼ + environment.Start + private realization + │ + ▼ + resolved Environment + │ + ┌─────────┴─────────┐ + ▼ ▼ + explicit test code Environment.Close +``` + +## Declared resource graph + +`environment.Spec` contains these resource families: + +```go +type Spec struct { + Chains []ChainSpec + IBCInstances []IBCInstanceSpec + Connections []ConnectionSpec + Attestors []AttestorSpec + Relayers []RelayerSpec +} +``` + +The graph has the following domain relationships: + +```text +Chain A ── hosts ── IBC Instance A + │ + IBC Connection + ┌──────┴──────┐ + IBC Client A IBC Client B + └──────┬──────┘ + │ +Chain B ── hosts ── IBC Instance B + +Attestor ── serves ── IBC Client +Relayer ── serves ── IBC Connection +``` + +- A Chain hosts zero or more IBC Instances. +- An IBC Connection is the reciprocal pair of IBC Clients that point at one another. The Clients have stable authored identities because other resources may reference them. +- An Attestor is declared independently and references the Client it serves; it is not Connection configuration. +- A Relayer is declared independently and references the Connections it serves. Multiple Relayers may serve one Connection. +- A directed Route, when a Relayer needs one, is configuration of that concrete Relayer. It is not an IBC resource and never substitutes for a Connection. + +Graph-addressable resources use distinct typed IDs. Declaration order has no lifecycle meaning; typed references determine dependencies. + +## Declaration + +`Spec` is durable desired state. It contains identities, references, and configuration, but no RPC clients, process handles, generated paths, or cleanup functions. + +`Runtime` supplies process-local endpoint and authority bindings named by the Spec. + +Concrete declarations express acquisition semantics when they genuinely differ. Managed and attached Chains, or new and existing protocol state, are separate variants. Creation and reuse are strict: a new declaration creates state, while an existing declaration requires an explicit locator. There is no implicit discovery or adoption. + +The design does not introduce a generic resource union, builder framework, universal create/attach mode, or generic capability registry. + +## Realization + +`environment.Start` is the external seam for realization. Its implementation may be divided into private modules and internal adapter seams, but callers see one operation: + +```go +func Start(context.Context, Spec, Runtime) (*Environment, error) +``` + +Behind that interface, realization: + +1. snapshots and validates the declaration and runtime bindings; +2. derives dependency order from typed references; +3. selects the concrete adapters required by each declaration; +4. creates or attaches resources and verifies their identity; +5. funds the private authorities required for managed-Chain protocol mutations; +6. waits until every declared resource is ready; +7. records diagnostics and acquired effects as work succeeds; and +8. rolls back owned ephemeral effects in reverse order if startup fails. + +`Start` returns a complete Environment or an error; it never returns a partial Environment or a public realization plan. Deterministic validation should happen before mutation where practical, but this is an implementation property rather than another caller-visible phase. + +## Resolved Environment + +`Environment` is the resolved counterpart of `Spec`. Stable authored IDs locate typed Chains, IBC Instances, Clients, Connections, Attestors, and Relayers. + +Resolved resources expose stable facts and verified capabilities required by tests. Provider internals, process names, generated configuration, and cleanup hooks remain hidden. + +Resolved Chains expose their RPC endpoints directly. Relayer and Attestor processes are declared resources realized by the Environment; their private configuration and workspaces never surface to tests. + +Errors and child-process responses pass through the harness boundary unchanged. + +Capabilities are derived from the concrete declaration, available authority, and adapter verification. Callers do not author capability booleans. Attachment grants connectivity, not ownership or mutation authority. + +Managed Chains expose provider-independent funding as an explicit capability: `EnsureEOABalance` idempotently establishes and verifies a minimum native-token balance for an externally owned account. Zero and code-bearing addresses are rejected before mutation. The concrete adapter privately chooses the mechanism, such as a development RPC on Anvil or a transfer from an Environment-owned treasury on Besu. Attached Chains do not expose funding; callers that bring credentials for them must provision those accounts out of band. + +`Environment` owns the local lifetime of every declared resource and handle. Operation handles resolve the adapter's current client when used, so a managed node restart does not stale an earlier binding. `Environment.Close` invalidates every operation handle before cleanup, releases owned ephemeral effects in reverse dependency order, closes local access to borrowed resources, preserves externally durable state, and reports cleanup failures. No other aggregate lifetime is nested beside it. + +## Test code and focused operation modules + +Tests explicitly sequence setup, actions, fault injection, observation, and assertions. This ordering is part of the behavior under test and should remain visible. + +Focused modules may hide vertical mechanics such as ABI encoding, RPC calls, transaction submission, event decoding, polling, or process control. They should not hide horizontal orchestration across resources behind an aggregate `Run`, callback session, or outcome that silently performs the whole test. + +`e2etest` is a testing adapter only. It selects reusable Specs and runtime bindings, applies test policy, calls `environment.Start`, and registers `Environment.Close` with test cleanup. It does not add another environment-shaped declaration or own any other resource lifecycle. + +## Test setup + +Applications used to produce observable traffic (the real IFT and GMP contracts) are ordinary test setup, not Environment resources: tests deploy them explicitly through focused operation modules and keep orchestration and lifecycle visible. + +## Assessment and test selection + +`environment.Requirements` names workflow-visible capabilities on stable authored resource IDs. Requirements are separate from `Spec`: they describe what a test needs from a selected Environment, not properties the declaration may grant to its resources. + +`environment.Assess(Spec, Runtime, Requirements)` is side-effect-free. It validates the complete selected declaration, runtime bindings, and requirement targets without filesystem, process, Docker, RPC, or network work. An invalid selection returns an error; capabilities the selection cannot guarantee produce a non-feasible `Assessment`. + +`e2etest.RequireCapabilities` owns testing policy at the test seam. It fails an invalid selection and skips a non-feasible interchangeable selection before acquisition. `environment.Start` remains solely responsible for realization and acquisition: it does not accept Requirements, decide whether to skip a test, or turn startup failures into unsupported selections. + +## Deliberately excluded + +- application or scenario fields, registries, resource families, or lifecycle aggregates in `environment.Spec`; +- a callback session or second aggregate runtime; +- capability booleans authored in Specs; +- implicit resource discovery, adoption, or ensure semantics; +- global provider-default test identities or faucet accounts; +- treating Relayer Routes as IBC Connections; and +- compatibility aliases for the superseded harness shape. + +## Open design work + +- Shape any further test-application bindings and attached-Chain effect reporting from concrete tests. +- Define diagnostic artifact schema and retention only when consumers require them. +- Model finality separately from timing when a real workflow requires it. +- Add process-placement controls only when a concrete standalone versus co-located case requires them. diff --git a/link/Makefile b/link/Makefile index 3fe13290d..182f4f827 100644 --- a/link/Makefile +++ b/link/Makefile @@ -14,10 +14,18 @@ BUF ?= go run github.com/bufbuild/buf/cmd/buf@v$(BUF_VERSION) endif help: ## List of commands - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +# Build metadata stamped into `ibc version` via -X ldflags. Overridable, e.g. +# `make build VERSION=v1.2.3`. Defaults derive from git when available. +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) build: ## Build the binary - go build -o bin/ibc ./cmd/ibc/... + mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o bin/ibc ./cmd/ibc/... lint: ## Lint the code golangci-lint run @@ -33,10 +41,23 @@ codegen-sql: ## Generate Go code based on migrations and queries codegen-proto: ## Generate Go proto bindings cd ../proto && $(BUF) generate +check-proto: ## Fail if Link's generated API is stale or the removed API module returns + @command -v buf >/dev/null || { echo "missing buf; buf is required to verify Link's generated API" >&2; exit 1; } + cd ../proto && buf generate + @status="$$(git -C .. status --porcelain --untracked-files=all -- \ + ':(glob)link/api/v2/**/*.pb.go' \ + ':(glob)link/api/v2/**/*.connect.go')"; \ + test -z "$$status" || { echo "Link API bindings are stale — run 'make codegen-proto' and commit the result" >&2; echo "$$status" >&2; exit 1; } + @test ! -e ../api || { echo "the removed standalone API module still exists" >&2; exit 1; } + @old_api='github.com/cosmos/ibc/'api; \ + ! git -C .. grep -n -F "$$old_api" -- . || { echo "the removed API module is still referenced" >&2; exit 1; } + codegen-mocks: ## Generate mocks for the code go run github.com/vektra/mockery/v3@v3.7.1 test: ## Run tests go test ./... -v -.PHONY: help build lint lint-fix test codegen codegen-sql codegen-proto codegen-mocks +check: build test lint check-proto ## Run Link build, tests, lint, and generation freshness + +.PHONY: help build lint lint-fix test codegen codegen-sql codegen-proto codegen-mocks check-proto check diff --git a/link/README.md b/link/README.md index 3117f0111..96b74910d 100644 --- a/link/README.md +++ b/link/README.md @@ -5,9 +5,20 @@ ## Setup ```bash -# create new config if ~/.ibc/ibc.yml -ibc config new +# build the CLI +make build + +# create a new config at ~/.ibc/ibc.yml +./bin/ibc config new # migrate the database -ibc migrate up -``` \ No newline at end of file +./bin/ibc migrate up +``` + +## E2E + +Repository-wide black-box tests live in [`../e2e/`](../e2e/README.md), with the harness in +`../e2e/internal/harness` as a separate Go module. From the repository root, +`make doctor-e2e && make test-e2e` runs the Link smoke suite. + +The accepted target for the harness is documented in [IBC Environment Architecture](HARNESS-ARCHITECTURE-DESIGN.md). diff --git a/link/internal/types/v2/attestor/attestor.connect.go b/link/api/v2/attestor/attestor.connect.go similarity index 58% rename from link/internal/types/v2/attestor/attestor.connect.go rename to link/api/v2/attestor/attestor.connect.go index 3eaaf9b69..e3572b640 100644 --- a/link/internal/types/v2/attestor/attestor.connect.go +++ b/link/api/v2/attestor/attestor.connect.go @@ -32,6 +32,12 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( + // AttestationServiceStateAttestationProcedure is the fully-qualified name of the + // AttestationService's StateAttestation RPC. + AttestationServiceStateAttestationProcedure = "/ibc.v2.attestor.AttestationService/StateAttestation" + // AttestationServicePacketAttestationProcedure is the fully-qualified name of the + // AttestationService's PacketAttestation RPC. + AttestationServicePacketAttestationProcedure = "/ibc.v2.attestor.AttestationService/PacketAttestation" // AttestationServiceLatestAttestableHeightProcedure is the fully-qualified name of the // AttestationService's LatestAttestableHeight RPC. AttestationServiceLatestAttestableHeightProcedure = "/ibc.v2.attestor.AttestationService/LatestAttestableHeight" @@ -39,6 +45,10 @@ const ( // AttestationServiceClient is a client for the ibc.v2.attestor.AttestationService service. type AttestationServiceClient interface { + // Retrieves an attestation for a state at a given height. + StateAttestation(context.Context, *connect.Request[StateAttestationRequest]) (*connect.Response[StateAttestationResponse], error) + // Retrieves an attestation for a set of packets. + PacketAttestation(context.Context, *connect.Request[PacketAttestationRequest]) (*connect.Response[PacketAttestationResponse], error) // Returns the latest height the attestor will generate attestations for. LatestAttestableHeight(context.Context, *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error) } @@ -54,6 +64,18 @@ func NewAttestationServiceClient(httpClient connect.HTTPClient, baseURL string, baseURL = strings.TrimRight(baseURL, "/") attestationServiceMethods := File_attestor_proto.Services().ByName("AttestationService").Methods() return &attestationServiceClient{ + stateAttestation: connect.NewClient[StateAttestationRequest, StateAttestationResponse]( + httpClient, + baseURL+AttestationServiceStateAttestationProcedure, + connect.WithSchema(attestationServiceMethods.ByName("StateAttestation")), + connect.WithClientOptions(opts...), + ), + packetAttestation: connect.NewClient[PacketAttestationRequest, PacketAttestationResponse]( + httpClient, + baseURL+AttestationServicePacketAttestationProcedure, + connect.WithSchema(attestationServiceMethods.ByName("PacketAttestation")), + connect.WithClientOptions(opts...), + ), latestAttestableHeight: connect.NewClient[LatestAttestableHeightRequest, LatestAttestableHeightResponse]( httpClient, baseURL+AttestationServiceLatestAttestableHeightProcedure, @@ -65,9 +87,21 @@ func NewAttestationServiceClient(httpClient connect.HTTPClient, baseURL string, // attestationServiceClient implements AttestationServiceClient. type attestationServiceClient struct { + stateAttestation *connect.Client[StateAttestationRequest, StateAttestationResponse] + packetAttestation *connect.Client[PacketAttestationRequest, PacketAttestationResponse] latestAttestableHeight *connect.Client[LatestAttestableHeightRequest, LatestAttestableHeightResponse] } +// StateAttestation calls ibc.v2.attestor.AttestationService.StateAttestation. +func (c *attestationServiceClient) StateAttestation(ctx context.Context, req *connect.Request[StateAttestationRequest]) (*connect.Response[StateAttestationResponse], error) { + return c.stateAttestation.CallUnary(ctx, req) +} + +// PacketAttestation calls ibc.v2.attestor.AttestationService.PacketAttestation. +func (c *attestationServiceClient) PacketAttestation(ctx context.Context, req *connect.Request[PacketAttestationRequest]) (*connect.Response[PacketAttestationResponse], error) { + return c.packetAttestation.CallUnary(ctx, req) +} + // LatestAttestableHeight calls ibc.v2.attestor.AttestationService.LatestAttestableHeight. func (c *attestationServiceClient) LatestAttestableHeight(ctx context.Context, req *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error) { return c.latestAttestableHeight.CallUnary(ctx, req) @@ -75,6 +109,10 @@ func (c *attestationServiceClient) LatestAttestableHeight(ctx context.Context, r // AttestationServiceHandler is an implementation of the ibc.v2.attestor.AttestationService service. type AttestationServiceHandler interface { + // Retrieves an attestation for a state at a given height. + StateAttestation(context.Context, *connect.Request[StateAttestationRequest]) (*connect.Response[StateAttestationResponse], error) + // Retrieves an attestation for a set of packets. + PacketAttestation(context.Context, *connect.Request[PacketAttestationRequest]) (*connect.Response[PacketAttestationResponse], error) // Returns the latest height the attestor will generate attestations for. LatestAttestableHeight(context.Context, *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error) } @@ -86,6 +124,18 @@ type AttestationServiceHandler interface { // and JSON codecs. They also support gzip compression. func NewAttestationServiceHandler(svc AttestationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { attestationServiceMethods := File_attestor_proto.Services().ByName("AttestationService").Methods() + attestationServiceStateAttestationHandler := connect.NewUnaryHandler( + AttestationServiceStateAttestationProcedure, + svc.StateAttestation, + connect.WithSchema(attestationServiceMethods.ByName("StateAttestation")), + connect.WithHandlerOptions(opts...), + ) + attestationServicePacketAttestationHandler := connect.NewUnaryHandler( + AttestationServicePacketAttestationProcedure, + svc.PacketAttestation, + connect.WithSchema(attestationServiceMethods.ByName("PacketAttestation")), + connect.WithHandlerOptions(opts...), + ) attestationServiceLatestAttestableHeightHandler := connect.NewUnaryHandler( AttestationServiceLatestAttestableHeightProcedure, svc.LatestAttestableHeight, @@ -94,6 +144,10 @@ func NewAttestationServiceHandler(svc AttestationServiceHandler, opts ...connect ) return "/ibc.v2.attestor.AttestationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case AttestationServiceStateAttestationProcedure: + attestationServiceStateAttestationHandler.ServeHTTP(w, r) + case AttestationServicePacketAttestationProcedure: + attestationServicePacketAttestationHandler.ServeHTTP(w, r) case AttestationServiceLatestAttestableHeightProcedure: attestationServiceLatestAttestableHeightHandler.ServeHTTP(w, r) default: @@ -105,6 +159,14 @@ func NewAttestationServiceHandler(svc AttestationServiceHandler, opts ...connect // UnimplementedAttestationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedAttestationServiceHandler struct{} +func (UnimplementedAttestationServiceHandler) StateAttestation(context.Context, *connect.Request[StateAttestationRequest]) (*connect.Response[StateAttestationResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ibc.v2.attestor.AttestationService.StateAttestation is not implemented")) +} + +func (UnimplementedAttestationServiceHandler) PacketAttestation(context.Context, *connect.Request[PacketAttestationRequest]) (*connect.Response[PacketAttestationResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ibc.v2.attestor.AttestationService.PacketAttestation is not implemented")) +} + func (UnimplementedAttestationServiceHandler) LatestAttestableHeight(context.Context, *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ibc.v2.attestor.AttestationService.LatestAttestableHeight is not implemented")) } diff --git a/link/api/v2/attestor/attestor.pb.go b/link/api/v2/attestor/attestor.pb.go new file mode 100644 index 000000000..fb70b64f0 --- /dev/null +++ b/link/api/v2/attestor/attestor.pb.go @@ -0,0 +1,564 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: attestor.proto + +package attestor + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Proof type for packet attestation. +type ProofType int32 + +const ( + // Packet commitment present in the attested chain's state (the packet's source). + ProofType_PROOF_TYPE_PACKET_COMMITMENT ProofType = 0 + // Acknowledgement present in the attested chain's state (the packet's destination). + ProofType_PROOF_TYPE_PACKET_ACKNOWLEDGEMENT ProofType = 1 + // Packet receipt absent from the attested chain's state (the packet's destination). + ProofType_PROOF_TYPE_PACKET_RECEIPT_ABSENCE ProofType = 2 +) + +// Enum value maps for ProofType. +var ( + ProofType_name = map[int32]string{ + 0: "PROOF_TYPE_PACKET_COMMITMENT", + 1: "PROOF_TYPE_PACKET_ACKNOWLEDGEMENT", + 2: "PROOF_TYPE_PACKET_RECEIPT_ABSENCE", + } + ProofType_value = map[string]int32{ + "PROOF_TYPE_PACKET_COMMITMENT": 0, + "PROOF_TYPE_PACKET_ACKNOWLEDGEMENT": 1, + "PROOF_TYPE_PACKET_RECEIPT_ABSENCE": 2, + } +) + +func (x ProofType) Enum() *ProofType { + p := new(ProofType) + *p = x + return p +} + +func (x ProofType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProofType) Descriptor() protoreflect.EnumDescriptor { + return file_attestor_proto_enumTypes[0].Descriptor() +} + +func (ProofType) Type() protoreflect.EnumType { + return &file_attestor_proto_enumTypes[0] +} + +func (x ProofType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProofType.Descriptor instead. +func (ProofType) EnumDescriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{0} +} + +// Request message for getting an attestation for a state at a given height. +type StateAttestationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identifies which attestor to route the request to. + Attestor string `protobuf:"bytes,1,opt,name=attestor,proto3" json:"attestor,omitempty"` + // The height to attest to. + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateAttestationRequest) Reset() { + *x = StateAttestationRequest{} + mi := &file_attestor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateAttestationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateAttestationRequest) ProtoMessage() {} + +func (x *StateAttestationRequest) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateAttestationRequest.ProtoReflect.Descriptor instead. +func (*StateAttestationRequest) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{0} +} + +func (x *StateAttestationRequest) GetAttestor() string { + if x != nil { + return x.Attestor + } + return "" +} + +func (x *StateAttestationRequest) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +// Response message for getting an attestation for a state at a given height. +type StateAttestationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attestation. + Attestation *Attestation `protobuf:"bytes,1,opt,name=attestation,proto3" json:"attestation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateAttestationResponse) Reset() { + *x = StateAttestationResponse{} + mi := &file_attestor_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateAttestationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateAttestationResponse) ProtoMessage() {} + +func (x *StateAttestationResponse) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateAttestationResponse.ProtoReflect.Descriptor instead. +func (*StateAttestationResponse) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{1} +} + +func (x *StateAttestationResponse) GetAttestation() *Attestation { + if x != nil { + return x.Attestation + } + return nil +} + +// Request message for getting an attestation for a set of packets. +// +// This request includes a height because packet attestations should be produced +// for a finalized, pinned chain height. +type PacketAttestationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identifies which attestor to route the request to. + Attestor string `protobuf:"bytes,1,opt,name=attestor,proto3" json:"attestor,omitempty"` + // The packets to attest to. Each element is an independently ABI-encoded packet. + Packets [][]byte `protobuf:"bytes,2,rep,name=packets,proto3" json:"packets,omitempty"` + // The height to attest to the packets at. + Height uint64 `protobuf:"varint,3,opt,name=height,proto3" json:"height,omitempty"` + // The proof type to attest. + ProofType ProofType `protobuf:"varint,4,opt,name=proof_type,json=proofType,proto3,enum=ibc.v2.attestor.ProofType" json:"proof_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketAttestationRequest) Reset() { + *x = PacketAttestationRequest{} + mi := &file_attestor_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketAttestationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketAttestationRequest) ProtoMessage() {} + +func (x *PacketAttestationRequest) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketAttestationRequest.ProtoReflect.Descriptor instead. +func (*PacketAttestationRequest) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{2} +} + +func (x *PacketAttestationRequest) GetAttestor() string { + if x != nil { + return x.Attestor + } + return "" +} + +func (x *PacketAttestationRequest) GetPackets() [][]byte { + if x != nil { + return x.Packets + } + return nil +} + +func (x *PacketAttestationRequest) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *PacketAttestationRequest) GetProofType() ProofType { + if x != nil { + return x.ProofType + } + return ProofType_PROOF_TYPE_PACKET_COMMITMENT +} + +// Response message for getting an attestation for a set of packets. +type PacketAttestationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attestation. + Attestation *Attestation `protobuf:"bytes,1,opt,name=attestation,proto3" json:"attestation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketAttestationResponse) Reset() { + *x = PacketAttestationResponse{} + mi := &file_attestor_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketAttestationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketAttestationResponse) ProtoMessage() {} + +func (x *PacketAttestationResponse) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketAttestationResponse.ProtoReflect.Descriptor instead. +func (*PacketAttestationResponse) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{3} +} + +func (x *PacketAttestationResponse) GetAttestation() *Attestation { + if x != nil { + return x.Attestation + } + return nil +} + +type LatestAttestableHeightRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Attestor string `protobuf:"bytes,1,opt,name=attestor,proto3" json:"attestor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LatestAttestableHeightRequest) Reset() { + *x = LatestAttestableHeightRequest{} + mi := &file_attestor_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LatestAttestableHeightRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LatestAttestableHeightRequest) ProtoMessage() {} + +func (x *LatestAttestableHeightRequest) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LatestAttestableHeightRequest.ProtoReflect.Descriptor instead. +func (*LatestAttestableHeightRequest) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{4} +} + +func (x *LatestAttestableHeightRequest) GetAttestor() string { + if x != nil { + return x.Attestor + } + return "" +} + +type LatestAttestableHeightResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LatestAttestableHeightResponse) Reset() { + *x = LatestAttestableHeightResponse{} + mi := &file_attestor_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LatestAttestableHeightResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LatestAttestableHeightResponse) ProtoMessage() {} + +func (x *LatestAttestableHeightResponse) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LatestAttestableHeightResponse.ProtoReflect.Descriptor instead. +func (*LatestAttestableHeightResponse) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{5} +} + +func (x *LatestAttestableHeightResponse) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +// Attestation is a single attestation from a given block height for the requested data. +type Attestation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The height of the attestation. + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + // The timestamp of the block. Absent for packet attestations. + Timestamp *uint64 `protobuf:"varint,2,opt,name=timestamp,proto3,oneof" json:"timestamp,omitempty"` + // The attested data. + AttestedData []byte `protobuf:"bytes,3,opt,name=attested_data,json=attestedData,proto3" json:"attested_data,omitempty"` + // The attestation signature. + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Attestation) Reset() { + *x = Attestation{} + mi := &file_attestor_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Attestation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Attestation) ProtoMessage() {} + +func (x *Attestation) ProtoReflect() protoreflect.Message { + mi := &file_attestor_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Attestation.ProtoReflect.Descriptor instead. +func (*Attestation) Descriptor() ([]byte, []int) { + return file_attestor_proto_rawDescGZIP(), []int{6} +} + +func (x *Attestation) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *Attestation) GetTimestamp() uint64 { + if x != nil && x.Timestamp != nil { + return *x.Timestamp + } + return 0 +} + +func (x *Attestation) GetAttestedData() []byte { + if x != nil { + return x.AttestedData + } + return nil +} + +func (x *Attestation) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +var File_attestor_proto protoreflect.FileDescriptor + +const file_attestor_proto_rawDesc = "" + + "\n" + + "\x0eattestor.proto\x12\x0fibc.v2.attestor\"M\n" + + "\x17StateAttestationRequest\x12\x1a\n" + + "\battestor\x18\x01 \x01(\tR\battestor\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\"Z\n" + + "\x18StateAttestationResponse\x12>\n" + + "\vattestation\x18\x01 \x01(\v2\x1c.ibc.v2.attestor.AttestationR\vattestation\"\xa3\x01\n" + + "\x18PacketAttestationRequest\x12\x1a\n" + + "\battestor\x18\x01 \x01(\tR\battestor\x12\x18\n" + + "\apackets\x18\x02 \x03(\fR\apackets\x12\x16\n" + + "\x06height\x18\x03 \x01(\x04R\x06height\x129\n" + + "\n" + + "proof_type\x18\x04 \x01(\x0e2\x1a.ibc.v2.attestor.ProofTypeR\tproofType\"[\n" + + "\x19PacketAttestationResponse\x12>\n" + + "\vattestation\x18\x01 \x01(\v2\x1c.ibc.v2.attestor.AttestationR\vattestation\";\n" + + "\x1dLatestAttestableHeightRequest\x12\x1a\n" + + "\battestor\x18\x01 \x01(\tR\battestor\"8\n" + + "\x1eLatestAttestableHeightResponse\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\"\x99\x01\n" + + "\vAttestation\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\x12!\n" + + "\ttimestamp\x18\x02 \x01(\x04H\x00R\ttimestamp\x88\x01\x01\x12#\n" + + "\rattested_data\x18\x03 \x01(\fR\fattestedData\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\fR\tsignatureB\f\n" + + "\n" + + "_timestamp*{\n" + + "\tProofType\x12 \n" + + "\x1cPROOF_TYPE_PACKET_COMMITMENT\x10\x00\x12%\n" + + "!PROOF_TYPE_PACKET_ACKNOWLEDGEMENT\x10\x01\x12%\n" + + "!PROOF_TYPE_PACKET_RECEIPT_ABSENCE\x10\x022\xe4\x02\n" + + "\x12AttestationService\x12g\n" + + "\x10StateAttestation\x12(.ibc.v2.attestor.StateAttestationRequest\x1a).ibc.v2.attestor.StateAttestationResponse\x12j\n" + + "\x11PacketAttestation\x12).ibc.v2.attestor.PacketAttestationRequest\x1a*.ibc.v2.attestor.PacketAttestationResponse\x12y\n" + + "\x16LatestAttestableHeight\x12..ibc.v2.attestor.LatestAttestableHeightRequest\x1a/.ibc.v2.attestor.LatestAttestableHeightResponseB,Z*github.com/cosmos/ibc/link/api/v2/attestorb\x06proto3" + +var ( + file_attestor_proto_rawDescOnce sync.Once + file_attestor_proto_rawDescData []byte +) + +func file_attestor_proto_rawDescGZIP() []byte { + file_attestor_proto_rawDescOnce.Do(func() { + file_attestor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_attestor_proto_rawDesc), len(file_attestor_proto_rawDesc))) + }) + return file_attestor_proto_rawDescData +} + +var file_attestor_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_attestor_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_attestor_proto_goTypes = []any{ + (ProofType)(0), // 0: ibc.v2.attestor.ProofType + (*StateAttestationRequest)(nil), // 1: ibc.v2.attestor.StateAttestationRequest + (*StateAttestationResponse)(nil), // 2: ibc.v2.attestor.StateAttestationResponse + (*PacketAttestationRequest)(nil), // 3: ibc.v2.attestor.PacketAttestationRequest + (*PacketAttestationResponse)(nil), // 4: ibc.v2.attestor.PacketAttestationResponse + (*LatestAttestableHeightRequest)(nil), // 5: ibc.v2.attestor.LatestAttestableHeightRequest + (*LatestAttestableHeightResponse)(nil), // 6: ibc.v2.attestor.LatestAttestableHeightResponse + (*Attestation)(nil), // 7: ibc.v2.attestor.Attestation +} +var file_attestor_proto_depIdxs = []int32{ + 7, // 0: ibc.v2.attestor.StateAttestationResponse.attestation:type_name -> ibc.v2.attestor.Attestation + 0, // 1: ibc.v2.attestor.PacketAttestationRequest.proof_type:type_name -> ibc.v2.attestor.ProofType + 7, // 2: ibc.v2.attestor.PacketAttestationResponse.attestation:type_name -> ibc.v2.attestor.Attestation + 1, // 3: ibc.v2.attestor.AttestationService.StateAttestation:input_type -> ibc.v2.attestor.StateAttestationRequest + 3, // 4: ibc.v2.attestor.AttestationService.PacketAttestation:input_type -> ibc.v2.attestor.PacketAttestationRequest + 5, // 5: ibc.v2.attestor.AttestationService.LatestAttestableHeight:input_type -> ibc.v2.attestor.LatestAttestableHeightRequest + 2, // 6: ibc.v2.attestor.AttestationService.StateAttestation:output_type -> ibc.v2.attestor.StateAttestationResponse + 4, // 7: ibc.v2.attestor.AttestationService.PacketAttestation:output_type -> ibc.v2.attestor.PacketAttestationResponse + 6, // 8: ibc.v2.attestor.AttestationService.LatestAttestableHeight:output_type -> ibc.v2.attestor.LatestAttestableHeightResponse + 6, // [6:9] is the sub-list for method output_type + 3, // [3:6] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_attestor_proto_init() } +func file_attestor_proto_init() { + if File_attestor_proto != nil { + return + } + file_attestor_proto_msgTypes[6].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_attestor_proto_rawDesc), len(file_attestor_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_attestor_proto_goTypes, + DependencyIndexes: file_attestor_proto_depIdxs, + EnumInfos: file_attestor_proto_enumTypes, + MessageInfos: file_attestor_proto_msgTypes, + }.Build() + File_attestor_proto = out.File + file_attestor_proto_goTypes = nil + file_attestor_proto_depIdxs = nil +} diff --git a/link/api/v2/attestor/readiness.go b/link/api/v2/attestor/readiness.go new file mode 100644 index 000000000..ad4a137a6 --- /dev/null +++ b/link/api/v2/attestor/readiness.go @@ -0,0 +1,10 @@ +package attestor + +// ProcessReadinessEvent identifies the attestor startup event. +const ProcessReadinessEvent = "ready" + +// ProcessReadiness announces the attestor's bound HTTP address. +type ProcessReadiness struct { + Event string `json:"event"` + HTTP string `json:"http"` +} diff --git a/link/api/v2/attestor/readiness_test.go b/link/api/v2/attestor/readiness_test.go new file mode 100644 index 000000000..0c992f7bd --- /dev/null +++ b/link/api/v2/attestor/readiness_test.go @@ -0,0 +1,23 @@ +package attestor_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/api/v2/attestor" +) + +func TestProcessReadiness(t *testing.T) { + t.Parallel() + + require.Equal(t, "ready", attestor.ProcessReadinessEvent) + + encoded, err := json.Marshal(attestor.ProcessReadiness{ + Event: attestor.ProcessReadinessEvent, + HTTP: "127.0.0.1:3000", + }) + require.NoError(t, err) + require.Equal(t, `{"event":"ready","http":"127.0.0.1:3000"}`, string(encoded)) +} diff --git a/link/internal/types/v2/relayer/relayer.connect.go b/link/api/v2/relayer/relayer.connect.go similarity index 74% rename from link/internal/types/v2/relayer/relayer.connect.go rename to link/api/v2/relayer/relayer.connect.go index ce9064ab0..2f652c4e3 100644 --- a/link/internal/types/v2/relayer/relayer.connect.go +++ b/link/api/v2/relayer/relayer.connect.go @@ -34,6 +34,9 @@ const ( const ( // RelayerApiServiceRelayProcedure is the fully-qualified name of the RelayerApiService's Relay RPC. RelayerApiServiceRelayProcedure = "/ibc.v2.relayer.RelayerApiService/Relay" + // RelayerApiServiceStatusProcedure is the fully-qualified name of the RelayerApiService's Status + // RPC. + RelayerApiServiceStatusProcedure = "/ibc.v2.relayer.RelayerApiService/Status" ) // RelayerApiServiceClient is a client for the ibc.v2.relayer.RelayerApiService service. @@ -41,6 +44,9 @@ type RelayerApiServiceClient interface { // Relay will track the status of a transfer, and submit the transactions // required to complete the transfer via the bridging protocol. Relay(context.Context, *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error) + // Status returns per-packet transfer status for a transaction previously + // submitted via Relay. + Status(context.Context, *connect.Request[StatusRequest]) (*connect.Response[StatusResponse], error) } // NewRelayerApiServiceClient constructs a client for the ibc.v2.relayer.RelayerApiService service. @@ -60,12 +66,19 @@ func NewRelayerApiServiceClient(httpClient connect.HTTPClient, baseURL string, o connect.WithSchema(relayerApiServiceMethods.ByName("Relay")), connect.WithClientOptions(opts...), ), + status: connect.NewClient[StatusRequest, StatusResponse]( + httpClient, + baseURL+RelayerApiServiceStatusProcedure, + connect.WithSchema(relayerApiServiceMethods.ByName("Status")), + connect.WithClientOptions(opts...), + ), } } // relayerApiServiceClient implements RelayerApiServiceClient. type relayerApiServiceClient struct { - relay *connect.Client[RelayRequest, RelayResponse] + relay *connect.Client[RelayRequest, RelayResponse] + status *connect.Client[StatusRequest, StatusResponse] } // Relay calls ibc.v2.relayer.RelayerApiService.Relay. @@ -73,11 +86,19 @@ func (c *relayerApiServiceClient) Relay(ctx context.Context, req *connect.Reques return c.relay.CallUnary(ctx, req) } +// Status calls ibc.v2.relayer.RelayerApiService.Status. +func (c *relayerApiServiceClient) Status(ctx context.Context, req *connect.Request[StatusRequest]) (*connect.Response[StatusResponse], error) { + return c.status.CallUnary(ctx, req) +} + // RelayerApiServiceHandler is an implementation of the ibc.v2.relayer.RelayerApiService service. type RelayerApiServiceHandler interface { // Relay will track the status of a transfer, and submit the transactions // required to complete the transfer via the bridging protocol. Relay(context.Context, *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error) + // Status returns per-packet transfer status for a transaction previously + // submitted via Relay. + Status(context.Context, *connect.Request[StatusRequest]) (*connect.Response[StatusResponse], error) } // NewRelayerApiServiceHandler builds an HTTP handler from the service implementation. It returns @@ -93,10 +114,18 @@ func NewRelayerApiServiceHandler(svc RelayerApiServiceHandler, opts ...connect.H connect.WithSchema(relayerApiServiceMethods.ByName("Relay")), connect.WithHandlerOptions(opts...), ) + relayerApiServiceStatusHandler := connect.NewUnaryHandler( + RelayerApiServiceStatusProcedure, + svc.Status, + connect.WithSchema(relayerApiServiceMethods.ByName("Status")), + connect.WithHandlerOptions(opts...), + ) return "/ibc.v2.relayer.RelayerApiService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case RelayerApiServiceRelayProcedure: relayerApiServiceRelayHandler.ServeHTTP(w, r) + case RelayerApiServiceStatusProcedure: + relayerApiServiceStatusHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -109,3 +138,7 @@ type UnimplementedRelayerApiServiceHandler struct{} func (UnimplementedRelayerApiServiceHandler) Relay(context.Context, *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ibc.v2.relayer.RelayerApiService.Relay is not implemented")) } + +func (UnimplementedRelayerApiServiceHandler) Status(context.Context, *connect.Request[StatusRequest]) (*connect.Response[StatusResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ibc.v2.relayer.RelayerApiService.Status is not implemented")) +} diff --git a/link/api/v2/relayer/relayer.pb.go b/link/api/v2/relayer/relayer.pb.go new file mode 100644 index 000000000..5c866278e --- /dev/null +++ b/link/api/v2/relayer/relayer.pb.go @@ -0,0 +1,503 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: relayer.proto + +package relayer + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TransferState int32 + +const ( + TransferState_TRANSFER_STATE_UNKNOWN TransferState = 0 + TransferState_TRANSFER_STATE_PENDING TransferState = 1 + TransferState_TRANSFER_STATE_COMPLETE TransferState = 2 + TransferState_TRANSFER_STATE_FAILED TransferState = 3 +) + +// Enum value maps for TransferState. +var ( + TransferState_name = map[int32]string{ + 0: "TRANSFER_STATE_UNKNOWN", + 1: "TRANSFER_STATE_PENDING", + 2: "TRANSFER_STATE_COMPLETE", + 3: "TRANSFER_STATE_FAILED", + } + TransferState_value = map[string]int32{ + "TRANSFER_STATE_UNKNOWN": 0, + "TRANSFER_STATE_PENDING": 1, + "TRANSFER_STATE_COMPLETE": 2, + "TRANSFER_STATE_FAILED": 3, + } +) + +func (x TransferState) Enum() *TransferState { + p := new(TransferState) + *p = x + return p +} + +func (x TransferState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TransferState) Descriptor() protoreflect.EnumDescriptor { + return file_relayer_proto_enumTypes[0].Descriptor() +} + +func (TransferState) Type() protoreflect.EnumType { + return &file_relayer_proto_enumTypes[0] +} + +func (x TransferState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TransferState.Descriptor instead. +func (TransferState) EnumDescriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{0} +} + +type RelayRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayRequest) Reset() { + *x = RelayRequest{} + mi := &file_relayer_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayRequest) ProtoMessage() {} + +func (x *RelayRequest) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayRequest.ProtoReflect.Descriptor instead. +func (*RelayRequest) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{0} +} + +func (x *RelayRequest) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *RelayRequest) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type RelayResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayResponse) Reset() { + *x = RelayResponse{} + mi := &file_relayer_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayResponse) ProtoMessage() {} + +func (x *RelayResponse) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayResponse.ProtoReflect.Descriptor instead. +func (*RelayResponse) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{1} +} + +type StatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusRequest) Reset() { + *x = StatusRequest{} + mi := &file_relayer_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusRequest) ProtoMessage() {} + +func (x *StatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. +func (*StatusRequest) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{2} +} + +func (x *StatusRequest) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *StatusRequest) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type StatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PacketStatuses []*PacketStatus `protobuf:"bytes,1,rep,name=packet_statuses,json=packetStatuses,proto3" json:"packet_statuses,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusResponse) Reset() { + *x = StatusResponse{} + mi := &file_relayer_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusResponse) ProtoMessage() {} + +func (x *StatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. +func (*StatusResponse) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{3} +} + +func (x *StatusResponse) GetPacketStatuses() []*PacketStatus { + if x != nil { + return x.PacketStatuses + } + return nil +} + +type TransactionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionInfo) Reset() { + *x = TransactionInfo{} + mi := &file_relayer_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionInfo) ProtoMessage() {} + +func (x *TransactionInfo) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionInfo.ProtoReflect.Descriptor instead. +func (*TransactionInfo) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{4} +} + +func (x *TransactionInfo) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *TransactionInfo) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type PacketStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + State TransferState `protobuf:"varint,1,opt,name=state,proto3,enum=ibc.v2.relayer.TransferState" json:"state,omitempty"` + SequenceNumber uint64 `protobuf:"varint,2,opt,name=sequence_number,json=sequenceNumber,proto3" json:"sequence_number,omitempty"` + SourceClientId string `protobuf:"bytes,3,opt,name=source_client_id,json=sourceClientId,proto3" json:"source_client_id,omitempty"` + SendTx *TransactionInfo `protobuf:"bytes,4,opt,name=send_tx,json=sendTx,proto3" json:"send_tx,omitempty"` + RecvTx *TransactionInfo `protobuf:"bytes,5,opt,name=recv_tx,json=recvTx,proto3" json:"recv_tx,omitempty"` + AckTx *TransactionInfo `protobuf:"bytes,6,opt,name=ack_tx,json=ackTx,proto3" json:"ack_tx,omitempty"` + TimeoutTx *TransactionInfo `protobuf:"bytes,7,opt,name=timeout_tx,json=timeoutTx,proto3" json:"timeout_tx,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketStatus) Reset() { + *x = PacketStatus{} + mi := &file_relayer_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketStatus) ProtoMessage() {} + +func (x *PacketStatus) ProtoReflect() protoreflect.Message { + mi := &file_relayer_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketStatus.ProtoReflect.Descriptor instead. +func (*PacketStatus) Descriptor() ([]byte, []int) { + return file_relayer_proto_rawDescGZIP(), []int{5} +} + +func (x *PacketStatus) GetState() TransferState { + if x != nil { + return x.State + } + return TransferState_TRANSFER_STATE_UNKNOWN +} + +func (x *PacketStatus) GetSequenceNumber() uint64 { + if x != nil { + return x.SequenceNumber + } + return 0 +} + +func (x *PacketStatus) GetSourceClientId() string { + if x != nil { + return x.SourceClientId + } + return "" +} + +func (x *PacketStatus) GetSendTx() *TransactionInfo { + if x != nil { + return x.SendTx + } + return nil +} + +func (x *PacketStatus) GetRecvTx() *TransactionInfo { + if x != nil { + return x.RecvTx + } + return nil +} + +func (x *PacketStatus) GetAckTx() *TransactionInfo { + if x != nil { + return x.AckTx + } + return nil +} + +func (x *PacketStatus) GetTimeoutTx() *TransactionInfo { + if x != nil { + return x.TimeoutTx + } + return nil +} + +var File_relayer_proto protoreflect.FileDescriptor + +const file_relayer_proto_rawDesc = "" + + "\n" + + "\rrelayer.proto\x12\x0eibc.v2.relayer\"B\n" + + "\fRelayRequest\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"\x0f\n" + + "\rRelayResponse\"C\n" + + "\rStatusRequest\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"W\n" + + "\x0eStatusResponse\x12E\n" + + "\x0fpacket_statuses\x18\x01 \x03(\v2\x1c.ibc.v2.relayer.PacketStatusR\x0epacketStatuses\"E\n" + + "\x0fTransactionInfo\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"\x82\x03\n" + + "\fPacketStatus\x123\n" + + "\x05state\x18\x01 \x01(\x0e2\x1d.ibc.v2.relayer.TransferStateR\x05state\x12'\n" + + "\x0fsequence_number\x18\x02 \x01(\x04R\x0esequenceNumber\x12(\n" + + "\x10source_client_id\x18\x03 \x01(\tR\x0esourceClientId\x128\n" + + "\asend_tx\x18\x04 \x01(\v2\x1f.ibc.v2.relayer.TransactionInfoR\x06sendTx\x128\n" + + "\arecv_tx\x18\x05 \x01(\v2\x1f.ibc.v2.relayer.TransactionInfoR\x06recvTx\x126\n" + + "\x06ack_tx\x18\x06 \x01(\v2\x1f.ibc.v2.relayer.TransactionInfoR\x05ackTx\x12>\n" + + "\n" + + "timeout_tx\x18\a \x01(\v2\x1f.ibc.v2.relayer.TransactionInfoR\ttimeoutTx*\x7f\n" + + "\rTransferState\x12\x1a\n" + + "\x16TRANSFER_STATE_UNKNOWN\x10\x00\x12\x1a\n" + + "\x16TRANSFER_STATE_PENDING\x10\x01\x12\x1b\n" + + "\x17TRANSFER_STATE_COMPLETE\x10\x02\x12\x19\n" + + "\x15TRANSFER_STATE_FAILED\x10\x032\xa6\x01\n" + + "\x11RelayerApiService\x12F\n" + + "\x05Relay\x12\x1c.ibc.v2.relayer.RelayRequest\x1a\x1d.ibc.v2.relayer.RelayResponse\"\x00\x12I\n" + + "\x06Status\x12\x1d.ibc.v2.relayer.StatusRequest\x1a\x1e.ibc.v2.relayer.StatusResponse\"\x00B+Z)github.com/cosmos/ibc/link/api/v2/relayerb\x06proto3" + +var ( + file_relayer_proto_rawDescOnce sync.Once + file_relayer_proto_rawDescData []byte +) + +func file_relayer_proto_rawDescGZIP() []byte { + file_relayer_proto_rawDescOnce.Do(func() { + file_relayer_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_relayer_proto_rawDesc), len(file_relayer_proto_rawDesc))) + }) + return file_relayer_proto_rawDescData +} + +var file_relayer_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_relayer_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_relayer_proto_goTypes = []any{ + (TransferState)(0), // 0: ibc.v2.relayer.TransferState + (*RelayRequest)(nil), // 1: ibc.v2.relayer.RelayRequest + (*RelayResponse)(nil), // 2: ibc.v2.relayer.RelayResponse + (*StatusRequest)(nil), // 3: ibc.v2.relayer.StatusRequest + (*StatusResponse)(nil), // 4: ibc.v2.relayer.StatusResponse + (*TransactionInfo)(nil), // 5: ibc.v2.relayer.TransactionInfo + (*PacketStatus)(nil), // 6: ibc.v2.relayer.PacketStatus +} +var file_relayer_proto_depIdxs = []int32{ + 6, // 0: ibc.v2.relayer.StatusResponse.packet_statuses:type_name -> ibc.v2.relayer.PacketStatus + 0, // 1: ibc.v2.relayer.PacketStatus.state:type_name -> ibc.v2.relayer.TransferState + 5, // 2: ibc.v2.relayer.PacketStatus.send_tx:type_name -> ibc.v2.relayer.TransactionInfo + 5, // 3: ibc.v2.relayer.PacketStatus.recv_tx:type_name -> ibc.v2.relayer.TransactionInfo + 5, // 4: ibc.v2.relayer.PacketStatus.ack_tx:type_name -> ibc.v2.relayer.TransactionInfo + 5, // 5: ibc.v2.relayer.PacketStatus.timeout_tx:type_name -> ibc.v2.relayer.TransactionInfo + 1, // 6: ibc.v2.relayer.RelayerApiService.Relay:input_type -> ibc.v2.relayer.RelayRequest + 3, // 7: ibc.v2.relayer.RelayerApiService.Status:input_type -> ibc.v2.relayer.StatusRequest + 2, // 8: ibc.v2.relayer.RelayerApiService.Relay:output_type -> ibc.v2.relayer.RelayResponse + 4, // 9: ibc.v2.relayer.RelayerApiService.Status:output_type -> ibc.v2.relayer.StatusResponse + 8, // [8:10] is the sub-list for method output_type + 6, // [6:8] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_relayer_proto_init() } +func file_relayer_proto_init() { + if File_relayer_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_relayer_proto_rawDesc), len(file_relayer_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_relayer_proto_goTypes, + DependencyIndexes: file_relayer_proto_depIdxs, + EnumInfos: file_relayer_proto_enumTypes, + MessageInfos: file_relayer_proto_msgTypes, + }.Build() + File_relayer_proto = out.File + file_relayer_proto_goTypes = nil + file_relayer_proto_depIdxs = nil +} diff --git a/link/cmd/ibc/AGENTS.md b/link/cmd/ibc/AGENTS.md index 0e722fdec..b13e4df56 100644 --- a/link/cmd/ibc/AGENTS.md +++ b/link/cmd/ibc/AGENTS.md @@ -1,8 +1,12 @@ # CLI development (located in cmd/ibc/) 1. Use a single `init()` function in main.go to wire all subcommands. Never create init() per CLI file. -2. Every command should be a variable, just like other commands, and start with a `cmd*` prefix. Examples: - - `ibc relayer run` corresponds to `var cmdRelayerRun = ...`; - - `ibc config validate` corresponds to `var cmdConfigValidate = ...`; -3. Every command implementation should be a func of the form `commandName(cmd *cobra.Command, args []string) error`. Be consistent and rely on existing conventions. All CLI logic should look consistent. +2. Commands implemented in this package should be variables and start with a `cmd*` prefix. The config, + relayer, and test-app families are constructed by importable command packages because their handlers + are selected at the composition root. Examples for commands that remain here: + - `ibc attestor run` corresponds to `var cmdAttestorRun = ...`; + - `ibc migrate up` corresponds to `var cmdMigrateUp = ...`; +3. Command implementations in this package should use `func commandName(cmd *cobra.Command, args []string) error`. + Importable command packages may pass command-owned option structs to injected handlers so flags stay + local to the command constructor. Be consistent within each command family. 4. Global CLI flags such as `--home` or `--config` belong in `config.FlagSet`. Add new global flags there, and don't use a separate variable if it can be global. diff --git a/link/cmd/ibc/attestor.go b/link/cmd/ibc/attestor.go index 16e75b7f0..c0a043040 100644 --- a/link/cmd/ibc/attestor.go +++ b/link/cmd/ibc/attestor.go @@ -1,10 +1,14 @@ package main import ( + "encoding/json" + "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/bootstrap" "github.com/cosmos/ibc/link/internal/pkg/graceful" + + attestorv2 "github.com/cosmos/ibc/link/api/v2/attestor" ) var ( @@ -20,7 +24,7 @@ var ( } ) -func attestorRun(_ *cobra.Command, _ []string) error { +func attestorRun(cmd *cobra.Command, _ []string) error { cfg, err := setupHomeWithConfig() if err != nil { return err @@ -37,7 +41,19 @@ func attestorRun(_ *cobra.Command, _ []string) error { app.Logger.Error("Failed to start attestor server", "error", err) return err } + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(attestorv2.ProcessReadiness{ + Event: attestorv2.ProcessReadinessEvent, + HTTP: app.Server.Addr(), + }); err != nil { + _ = app.Server.Stop() + return err + } + // Callbacks run in LIFO order: stop the server first, then Services.Close. + // Attestor-mode Services owns no store or chain clients, but Close still flushes + // and shuts down the OTLP tracer provider (BuildAttestor installs it), so both + // callbacks are required for a clean shutdown. + graceful.AddCallback(app.Close) graceful.AddCallback(app.Server.Stop) // blocking diff --git a/link/cmd/ibc/config.go b/link/cmd/ibc/config.go index cadf9bfaf..1b9b4c0e5 100644 --- a/link/cmd/ibc/config.go +++ b/link/cmd/ibc/config.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" @@ -8,7 +9,8 @@ import ( "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/config" - "github.com/cosmos/ibc/link/internal/store" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" + "github.com/cosmos/ibc/link/internal/preflight" ) // set in init() @@ -62,64 +64,162 @@ func configNew(_ *cobra.Command, _ []string) error { return nil } +type configValidationResult struct { + StructurallyValid bool `json:"structurallyValid"` + ResolvedChains []string `json:"resolvedChains,omitempty"` + Warnings []config.ValidationIssue `json:"warnings"` + Errors []config.ValidationIssue `json:"errors,omitempty"` +} + +// configValidate prints a configValidationResult JSON document to stdout in ALL +// cases and maps the outcome to a sysexits exit code: +// - unparseable / structurally invalid config -> structurallyValid:false, exit 64 +// - structurally valid without --live -> structurallyValid:true, exit 0 +// - --live with live failures -> structurallyValid:true + errors, exit 65 func configValidate(_ *cobra.Command, _ []string) error { - cfg, err := setupHomeWithConfig() + configPath, err := prepareHome() if err != nil { - return errors.Wrap(err, "setup home with config") + return exitcode.New(exitcode.Internal, errors.Wrap(err, "setup home")) + } + + cfg, err := config.LoadFromFile(configPath, flagConfigValidateStrict) + if err != nil { + result := configValidationResult{ + Warnings: []config.ValidationIssue{}, + Errors: []config.ValidationIssue{{Path: configPath, Message: err.Error()}}, + } + + return emitResult(result, exitcode.ConfigInvalid, err) + } + + // apply --db override before structural validation + if globalFlags.DB != "" { + cfg.DB = config.DBConfigFromURL(globalFlags.DB) + } + + validation := cfg.Validate() + if len(validation.Errors) > 0 { + result := configValidationResult{ + Warnings: validation.Warnings, + Errors: validation.Errors, + } + + return emitResult(result, exitcode.ConfigInvalid, errors.New("config is invalid")) + } + + result := configValidationResult{ + StructurallyValid: true, + ResolvedChains: cfg.ChainIDs(), + Warnings: validation.Warnings, } if flagConfigValidateLive { - if err := store.ValidateConfigLive(cfg); err != nil { - return errors.Wrap(err, "config live validation") + if liveErrs := preflight.ValidateConfigLive(context.Background(), cfg); len(liveErrs) > 0 { + // Live failures preserve structural validity but map to exit 65. + result.Errors = liveErrs + + return emitResult(result, exitcode.RPCUnreachable, errors.New("live validation failed")) } } - // todo: it still logs store's log, we need to add config.logging{} params - // to truly suppress logging (in followup PRs) - if !globalFlags.Quiet { - return config.PrintJSON(map[string]any{"status": "valid"}) + return emitResult(result, exitcode.OK, nil) +} + +// emitResult always prints the JSON result to stdout, then returns a typed +// exitcode error (nil for exit 0) for main to translate into a process exit. +func emitResult(result configValidationResult, code int, cause error) error { + if err := config.PrintJSON(result); err != nil { + return exitcode.New(exitcode.Internal, errors.Wrap(err, "print result")) } - return nil + return exitcode.New(code, cause) } func printConfigHome(_ *cobra.Command, _ []string) { if !globalFlags.Quiet { - fmt.Printf("Using home: %s\n", globalFlags.Home) + fmt.Fprintf(os.Stderr, "Using home: %s\n", globalFlags.Home) } } -// setupHomeWithConfig changes process directory to `--home` and parses the config -func setupHomeWithConfig() (config.Config, error) { +// prepareHome expands `--home`, ensures it exists, changes the process working +// directory into it, and returns the resolved config file path. It does NOT load +// the config. +func prepareHome() (string, error) { home, err := config.ExpandHome(globalFlags.Home) if err != nil { - return config.Config{}, errors.Wrap(err, "home") + return "", errors.Wrap(err, "home") } configPath, err := globalFlags.ConfigPath() if err != nil { - return config.Config{}, errors.Wrap(err, "unable to get config path") + return "", errors.Wrap(err, "unable to get config path") } // ensure --home exists if err = config.EnsureDirectory(configPath); err != nil { - return config.Config{}, errors.Wrapf(err, "unable to create home directory %s", home) + return "", errors.Wrapf(err, "unable to create home directory %s", home) } if err = os.Chdir(home); err != nil { - return config.Config{}, errors.Wrapf(err, "unable to change working directory to %s", home) + return "", errors.Wrapf(err, "unable to change working directory to %s", home) + } + + return configPath, nil +} + +// setupHomeWithConfig changes process directory to `--home` and parses the config +func setupHomeWithConfig() (config.Config, error) { + configPath, err := prepareHome() + if err != nil { + return config.Config{}, err } - cfg, err := config.LoadFromFile(configPath, globalFlags.ValidateConfig(), false) + // Load WITHOUT fail-fast validation so a --db override can rescue an otherwise + // invalid db value before the effective config is validated. --strict (unknown + // field rejection) is a parse-time option and is always honored regardless. + cfg, err := config.LoadFromFile(configPath, flagConfigValidateStrict) if err != nil { return config.Config{}, err } // allow db override if globalFlags.DB != "" { - cfg.DB, err = config.DBConfigFromURL(globalFlags.DB) - if err != nil { - return config.Config{}, errors.Wrap(err, "invalid --db") + cfg.DB = config.DBConfigFromURL(globalFlags.DB) + } + + // Validate the EFFECTIVE (post-override) config. For the no-override path this + // is identical to validating at load time; with an override it validates the + // value the daemon will actually use. Honors --skip-validation via ValidateConfig(). + if globalFlags.ValidateConfig() { + if err := cfg.Validate().Err(); err != nil { + return config.Config{}, errors.Wrap(err, "validation failed") + } + } + + return cfg, nil +} + +// setupHomeWithDBConfig loads the config validating ONLY the db section: +// database-only commands (migrate) must work against configs that are not yet +// fully deployable (missing routers, foreign chain schemas). +func setupHomeWithDBConfig() (config.Config, error) { + configPath, err := prepareHome() + if err != nil { + return config.Config{}, err + } + + cfg, err := config.LoadFromFile(configPath, flagConfigValidateStrict) + if err != nil { + return config.Config{}, err + } + + if globalFlags.DB != "" { + cfg.DB = config.DBConfigFromURL(globalFlags.DB) + } + + if globalFlags.ValidateConfig() { + if err := cfg.DB.Validate().Err(); err != nil { + return config.Config{}, errors.Wrap(err, "validation failed") } } diff --git a/link/cmd/ibc/config_test.go b/link/cmd/ibc/config_test.go new file mode 100644 index 000000000..cdc443403 --- /dev/null +++ b/link/cmd/ibc/config_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" +) + +// withHome points globalFlags at a fresh temp home containing configYAML and +// restores the previous globalFlags and working directory afterward (setupHome* +// chdirs into the home). +func withHome(t *testing.T, configYAML string) { + t.Helper() + + home := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(home, "ibc.yml"), []byte(configYAML), 0o600)) + + cwd, err := os.Getwd() + require.NoError(t, err) + + prev := globalFlags + globalFlags = config.DefaultFlagSet() + globalFlags.Home = home + globalFlags.Quiet = true + + t.Cleanup(func() { + globalFlags = prev + _ = os.Chdir(cwd) + }) +} + +// minimalConfigWithBadDB is structurally valid except for an unsupported db.type, +// which only a --db override can rescue. +const minimalConfigWithBadDB = `server: + listenAddr: 127.0.0.1:8080 +db: + type: mysql + url: ignored +` + +// TestSetupHomeDBOverrideRescuesInvalidFileDB verifies the contract that the +// --db override is applied BEFORE validation, so an invalid db in the file is +// validated against the effective (overridden) value rather than failing fast +// at load time. +func TestSetupHomeDBOverrideRescuesInvalidFileDB(t *testing.T) { + t.Run("overrideRescues", func(t *testing.T) { + withHome(t, minimalConfigWithBadDB) + globalFlags.DB = "postgres://user:pass@localhost:5432/ibc" + + cfg, err := setupHomeWithConfig() + require.NoError(t, err) + assert.Equal(t, config.DBTypePostgres, cfg.DB.Type) + assert.Equal(t, "postgres://user:pass@localhost:5432/ibc", cfg.DB.URL) + }) + + t.Run("noOverrideStillValidatesEffectiveConfig", func(t *testing.T) { + withHome(t, minimalConfigWithBadDB) + + _, err := setupHomeWithConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "db.type") + }) +} diff --git a/link/cmd/ibc/deploy.go b/link/cmd/ibc/deploy.go new file mode 100644 index 000000000..7acec87ee --- /dev/null +++ b/link/cmd/ibc/deploy.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/deploy" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" +) + +var ( + flagDeployDryRun bool + flagDeploySigner string + flagDeployWriteConfig bool +) + +var ( + cmdDeploy = &cobra.Command{ + Use: "deploy ", + Short: "Deploy the IBC contracts (clients, IFT, GMP) for a connection", + Long: "Deploy the Solidity IBC contracts for the connection identified by a relayer client alias " + + "on both chains: AccessManager + ICS26Router, the ICS27 GMP app, an IFT token, and the " + + "attestation light clients; then open the relayer functions to the public role. Does not " + + "cross-wire the IFT bridges (use `ibc connect` for the full one-step flow). The instance and " + + "clients are skipped when they already exist.", + Args: cobra.ExactArgs(1), + RunE: deployCmd, + } + + cmdConnect = &cobra.Command{ + Use: "connect ", + Short: "Open a connection: deploy contracts and connect in one step", + Long: "Run `deploy` (instance, GMP, IFT, attestation clients, and public relay authorization on both " + + "chains) and additionally cross-wire the IFT bridges. The instance and clients are skipped when " + + "they already exist; a connection whose apps are already deployed fails loud rather than " + + "redeploying over live contracts.", + Args: cobra.ExactArgs(1), + RunE: connectCmd, + } +) + +func deployCmd(cmd *cobra.Command, args []string) error { + return runDeploy(cmd, args[0], false) +} + +func connectCmd(cmd *cobra.Command, args []string) error { + return runDeploy(cmd, args[0], true) +} + +// runDeploy drives `ibc deploy` and `ibc connect`. It prints a single JSON +// result document to stdout; all logs go to stderr. +func runDeploy(cmd *cobra.Command, alias string, connect bool) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + cfg, err := setupHomeWithConfig() + if err != nil { + return exitcode.New(exitcode.ConfigInvalid, err) + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + opts := deploy.Options{ + SignerAlias: flagDeploySigner, + DryRun: flagDeployDryRun, + Logger: logger, + } + + var result *deploy.Result + if connect { + result, err = deploy.Connect(ctx, cfg, alias, opts) + } else { + result, err = deploy.Deploy(ctx, cfg, alias, opts) + } + + // A run can fail mid-flight after durable contracts (router addresses) were + // already deployed. deploy returns the partial result alongside the error, so + // persist anything durable and always print the result before surfacing the + // failure with a nonzero exit. + var writeErr error + if flagDeployWriteConfig && !flagDeployDryRun && result != nil { + if werr := writeDeployedConfig(result, logger); werr != nil { + // Failing to patch the config leaves the deployed addresses only in the + // result JSON. Do not swallow it: print the result below (so the operator + // can recover the addresses), then exit non-zero so the failure is not + // mistaken for a clean run. + fmt.Fprintf(os.Stderr, "Error: --write-config failed: %v (patch the config manually)\n", werr) + writeErr = werr + } + } + + if result != nil { + if perr := config.PrintJSON(result); perr != nil { + if err == nil { + return exitcode.New(exitcode.Internal, errors.Wrap(perr, "print result")) + } + fmt.Fprintf(os.Stderr, "Warning: print result failed: %v\n", perr) + } + } + + if err != nil { + return exitcode.New(exitcode.Internal, err) + } + if writeErr != nil { + // The human-readable failure was already printed to stderr above (interleaved + // with the result JSON for context); cobra's default error printing would + // otherwise repeat the same message a second time, so silence it here and + // only surface the nonzero exit code. + cmd.SilenceErrors = true + return exitcode.New(exitcode.Internal, writeErr) + } + return nil +} + +func writeDeployedConfig(result *deploy.Result, logger *slog.Logger) error { + configPath, err := globalFlags.ConfigPath() + if err != nil { + return err + } + + changes, err := deploy.PatchConfigFile(configPath, result) + if err != nil { + return err + } + if len(changes) == 0 { + logger.Info("--write-config: no config changes needed") + return nil + } + + for _, change := range changes { + logger.Info("--write-config applied", "change", change) + } + fmt.Fprintf(os.Stderr, "Notice: config router addresses patched in place at %s.\n", configPath) + return nil +} diff --git a/link/cmd/ibc/keys.go b/link/cmd/ibc/keys.go index 1c9cd8e94..bb2f20adf 100644 --- a/link/cmd/ibc/keys.go +++ b/link/cmd/ibc/keys.go @@ -3,6 +3,7 @@ package main import ( "encoding/hex" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/config" @@ -57,11 +58,16 @@ func keysNew(_ *cobra.Command, args []string) error { if !saveKey { // for ephemeral keys we print the key to stdout including the private key - return config.PrintJSON(map[string]any{ + out := map[string]any{ "keyType": keyType, "publicKey": toHex(key.PublicKey()), "privateKey": toHex(key.PrivateKey()), - }) + } + if err = addEVMAddress(out, keyType, key.PublicKey()); err != nil { + return err + } + + return config.PrintJSON(out) } keyPath, err := signer.KeyFilePath(globalFlags.Home, args[1]) @@ -69,16 +75,21 @@ func keysNew(_ *cobra.Command, args []string) error { return err } - if err := key.StoreToFile(keyPath); err != nil { + if err = key.StoreToFile(keyPath); err != nil { return err } // note that we don't print the private key here to avoid leaking it to the user - return config.PrintJSON(map[string]any{ + out := map[string]any{ "keyType": keyType, "keyPath": keyPath, "publicKey": toHex(key.PublicKey()), - }) + } + if err = addEVMAddress(out, keyType, key.PublicKey()); err != nil { + return err + } + + return config.PrintJSON(out) } func keysShow(_ *cobra.Command, args []string) error { @@ -104,6 +115,9 @@ func keysShow(_ *cobra.Command, args []string) error { "keyType": key.Type(), "publicKey": toHex(key.PublicKey()), } + if err = addEVMAddress(kv, key.Type(), key.PublicKey()); err != nil { + return err + } if flagKeysShowPrivate { kv["privateKey"] = toHex(key.PrivateKey()) @@ -112,6 +126,21 @@ func keysShow(_ *cobra.Command, args []string) error { return config.PrintJSON(kv) } +func addEVMAddress(out map[string]any, keyType signer.KeyType, pubKey []byte) error { + if keyType != signer.ECDSA { + return nil + } + + addr, err := signer.EVMAddress(pubKey) + if err != nil { + return errors.Wrap(err, "deriving evm address from secp256k1 public key") + } + + out["evmAddress"] = addr.Hex() + + return nil +} + func toHex(b []byte) string { return "0x" + hex.EncodeToString(b) } diff --git a/link/cmd/ibc/keys_test.go b/link/cmd/ibc/keys_test.go new file mode 100644 index 000000000..d23265735 --- /dev/null +++ b/link/cmd/ibc/keys_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKeysCommandsEmitEVMAddress(t *testing.T) { + t.Run("ecdsa new and show include evmAddress", func(t *testing.T) { + withHome(t, minimalStatusConfig) + + out := captureStdout(t, func() { + require.NoError(t, keysNew(cmdKeysNew, []string{"ecdsa", "test-key"})) + }) + + var created map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &created), "keys new must emit valid JSON: %s", out) + addr, ok := created["evmAddress"].(string) + require.True(t, ok, "keys new must emit evmAddress for an ecdsa key: %s", out) + assert.Regexp(t, `^0x[0-9a-fA-F]{40}$`, addr) + + out = captureStdout(t, func() { + require.NoError(t, keysShow(cmdKeysShow, []string{"test-key"})) + }) + + var shown map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &shown), "keys show must emit valid JSON: %s", out) + assert.Equal(t, addr, shown["evmAddress"]) + }) + + t.Run("ecdsa ephemeral new includes evmAddress", func(t *testing.T) { + withHome(t, minimalStatusConfig) + + out := captureStdout(t, func() { + require.NoError(t, keysNew(cmdKeysNew, []string{"ecdsa"})) + }) + + var created map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &created)) + assert.Regexp(t, `^0x[0-9a-fA-F]{40}$`, created["evmAddress"]) + }) + + t.Run("eddsa omits evmAddress", func(t *testing.T) { + withHome(t, minimalStatusConfig) + + out := captureStdout(t, func() { + require.NoError(t, keysNew(cmdKeysNew, []string{"eddsa", "ed-key"})) + }) + + var created map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &created)) + _, ok := created["evmAddress"] + assert.False(t, ok, "ed25519 keys have no EVM address") + }) +} diff --git a/link/cmd/ibc/main.go b/link/cmd/ibc/main.go index 18e5f79fb..ebdfa7c1a 100644 --- a/link/cmd/ibc/main.go +++ b/link/cmd/ibc/main.go @@ -1,11 +1,14 @@ package main import ( + "errors" "os" "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" + "github.com/cosmos/ibc/link/internal/service/status" ) // global globalFlags, loaded in config.DeclarePersistentFlags() @@ -18,12 +21,27 @@ var rootCmd = &cobra.Command{ func main() { if err := rootCmd.Execute(); err != nil { - os.Exit(1) + var ec exitcode.Error + if errors.As(err, &ec) { + os.Exit(ec.Code) + } + + // unclassified errors are treated as internal failures + os.Exit(exitcode.Internal) } } // single init() for binding all commands to rootCmd func init() { + // don't dump full usage on runtime errors; keep stderr clean for the JSON + // contract on stdout. RunE errors still print as "Error: ..." to stderr. + rootCmd.SilenceUsage = true + + // flag parse errors map to the config-invalid exit code (64) + rootCmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return exitcode.New(exitcode.ConfigInvalid, err) + }) + // setup global flags config.DeclarePersistentFlags(rootCmd, &globalFlags) @@ -31,11 +49,29 @@ func init() { cmdConfig, cmdRelayer, cmdAttestor, - cmdQuery, + cmdStatus, cmdMigrate, cmdKeys, + cmdDeploy, + cmdConnect, + cmdVersion, ) + // Status command + cmdStatus.Flags().DurationVarP(&flagStatusStuckAfter, "stuck-after", "", status.DefaultStuckAfter, + "age past which a non-terminal packet is reported as stuck") + cmdStatus.Flags().StringVarP(&flagStatusAPIAddr, "api-addr", "", "", + "fetch the operator view from a running daemon's HTTP API (host:port or URL) instead of the shared DB") + + // Deploy/connect commands (one-step contract deployment + connection). + for _, cmd := range []*cobra.Command{cmdDeploy, cmdConnect} { + cmd.Flags().BoolVarP(&flagDeployDryRun, "dry-run", "", false, "print the planned tx sequence as JSON and exit") + cmd.Flags(). + StringVarP(&flagDeploySigner, "signer", "", "", "deployer signer alias (defaults to relayer.signer)") + cmd.Flags().BoolVarP(&flagDeployWriteConfig, "write-config", "", false, + "patch deployed router addresses into the config file in place (comments and ordering preserved)") + } + // Config commands cmdConfig.AddCommand(cmdConfigNew, cmdConfigValidate) cmdConfigValidate.Flags().BoolVarP(&flagConfigValidateLive, "live", "", false, "extra validation checks") @@ -53,9 +89,6 @@ func init() { // Attestor commands cmdAttestor.AddCommand(cmdAttestorRun) - // Query commands - // todo - // Migrate commands cmdMigrate.AddCommand(cmdMigrateUp, cmdMigrateDown, cmdMigrateStatus) } diff --git a/link/cmd/ibc/migrate.go b/link/cmd/ibc/migrate.go index b17154b71..15c8a0261 100644 --- a/link/cmd/ibc/migrate.go +++ b/link/cmd/ibc/migrate.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" "github.com/cosmos/ibc/link/internal/store" ) @@ -44,11 +45,11 @@ func migrateUp(_ *cobra.Command, _ []string) error { applied, err := db.MigrateUp() if err != nil { - return err + return exitcode.New(exitcode.Internal, err) } return config.PrintJSON(map[string]any{ - "db": cfg.DB.Label(), + "db": cfg.DB.Type, "applied": applied, }) } @@ -91,14 +92,16 @@ func migrateStatus(_ *cobra.Command, _ []string) error { // util to quickly resolve config + store. Use ONLY for migrations. func resolveConfigWithStore() (config.Config, store.Store, error) { - cfg, err := setupHomeWithConfig() + cfg, err := setupHomeWithDBConfig() if err != nil { - return config.Config{}, nil, err + return config.Config{}, nil, exitcode.New(exitcode.ConfigInvalid, err) } + // store creation opens (and for postgres pings) the database; failure here + // means the DB is unreachable. db, err := store.NewStore(context.Background(), cfg) if err != nil { - return config.Config{}, nil, err + return config.Config{}, nil, exitcode.New(exitcode.RPCUnreachable, err) } return cfg, db, nil diff --git a/link/cmd/ibc/query.go b/link/cmd/ibc/query.go deleted file mode 100644 index e4a094d61..000000000 --- a/link/cmd/ibc/query.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var cmdQuery = &cobra.Command{ - Use: "query", - Short: "Query commands", - Aliases: []string{"q"}, - RunE: queryRun, -} - -func queryRun(_ *cobra.Command, _ []string) error { - fmt.Println("Querying something...") - - return nil -} diff --git a/link/cmd/ibc/relayer.go b/link/cmd/ibc/relayer.go index 4c1b6d375..42ea9f280 100644 --- a/link/cmd/ibc/relayer.go +++ b/link/cmd/ibc/relayer.go @@ -1,11 +1,24 @@ package main import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "sync" + "time" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/cosmos/ibc/link/internal/bootstrap" + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/chains/manager" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" "github.com/cosmos/ibc/link/internal/pkg/graceful" + "github.com/cosmos/ibc/link/internal/server" ) var ( @@ -23,41 +36,223 @@ var ( var flagRelayerNoMigrate bool +// chainProbeTimeout bounds a single chain liveness probe at startup. +const chainProbeTimeout = 5 * time.Second + +// buildProbeTimeout bounds the total build-time chain RPC probes (e.g. the +// eth_chainId fetch per chain) so a hung RPC cannot stall startup forever. +const buildProbeTimeout = 15 * time.Second + +// relayerRun brings the daemon up in a fixed order and prints exactly one +// readiness line to stdout once it is serving: +// +// build (fail fast) -> migrate -> probe chains -> start server (bind listener) +// -> start engine/monitor -> print readiness -> wait for shutdown signal. +// +// All logs go to stderr; stdout carries only the readiness JSON line. func relayerRun(_ *cobra.Command, _ []string) error { cfg, err := setupHomeWithConfig() if err != nil { - return err + return exitcode.New(exitcode.ConfigInvalid, err) } - app, err := bootstrap.BuildRelayer(cfg) + buildCtx, cancelBuild := context.WithTimeout(context.Background(), buildProbeTimeout) + defer cancelBuild() + + app, err := bootstrap.BuildRelayer(buildCtx, cfg) if err != nil { + var probeErr *bootstrap.ProbeError + var mismatchErr *evm.ChainIDMismatchError + switch { + case errors.As(err, &probeErr): + return exitcode.New(exitcode.RPCUnreachable, errors.Wrap(err, "building relayer")) + case errors.As(err, &mismatchErr): + return exitcode.New(exitcode.ConfigInvalid, errors.Wrap(err, "building relayer")) + default: + return exitcode.New(exitcode.NotReady, errors.Wrap(err, "building relayer")) + } + } + + if err = runMigrations(app); err != nil { return err } - if flagRelayerNoMigrate { - app.Logger.Info("--no-migrate flag passed, skipping migrations") - } else { - applied, err := app.Store.MigrateUp() - switch { - case err != nil: - return errors.Wrap(err, "failed to migrate database") - case applied == 0: - app.Logger.Info("No migrations to apply") - case applied > 0: - app.Logger.Info("Migrated database", "migrations_applied", applied) - } + // Confirm the store is actually reachable before declaring dbReady. + if err = app.Store.Ping(app.Context); err != nil { + return exitcode.New(exitcode.NotReady, errors.Wrap(err, "store not ready")) + } + + // Probe every configured chain once; a single unreachable RPC fails startup. + // Chains the build already probed (relayed chains) are not re-dialed. + chainsConnected, err := probeChains(app.Context, cfg, app.ChainClients, app.ConnectedChains) + if err != nil { + return exitcode.New(exitcode.RPCUnreachable, err) } app.Logger.Info("Starting relayer") - if err := app.Server.Start(); err != nil { - app.Logger.Error("Failed to start relayer server", "error", err) - return err + // Bind the listener (Addr() is valid once Start returns) before starting the + // engine so the readiness line can report the real, possibly-ephemeral port. + if err = app.Server.Start(); err != nil { + return exitcode.New(exitcode.Internal, errors.Wrap(err, "starting relayer server")) } - graceful.AddCallback(app.Store.Close) + // Callbacks run in LIFO order on shutdown. Register so the ordering is: + // 1. stop the relay engine + auto-relay monitor (cancel ctx, wait for Run) + // 2. stop the server + // 3. close the store and chain clients + // The engine must stop before the server so no relay work is in flight when + // connections close; clients/store close last so nothing they back is used. + graceful.AddCallback(app.Close) graceful.AddCallback(app.Server.Stop) + graceful.AddCallback(startRelayLoops(app)) + + if err = printReadiness(app, chainsConnected); err != nil { + return exitcode.New(exitcode.Internal, errors.Wrap(err, "printing readiness")) + } // blocking return graceful.WaitShutdown() } + +func runMigrations(app *bootstrap.Services) error { + if flagRelayerNoMigrate { + app.Logger.Info("--no-migrate flag passed, skipping migrations") + return nil + } + + applied, err := app.Store.MigrateUp() + switch { + case err != nil: + return exitcode.New(exitcode.Internal, errors.Wrap(err, "failed to migrate database")) + case applied == 0: + app.Logger.Info("No migrations to apply") + case applied > 0: + app.Logger.Info("Migrated database", "migrations_applied", applied) + } + + return nil +} + +// chainIDProber is the subset of the EVM client used for the liveness probe. +type chainIDProber interface { + ChainID(ctx context.Context) (*big.Int, error) +} + +// probeChains dials every configured chain once (eth_chainId) and returns the +// full list of connected chain ids. Chains in alreadyConnected were probed and +// identity-checked during build, so they are carried through without re-dialing. +// The first failure aborts and is surfaced to the operator on stderr; the caller +// maps it to exit 65. +func probeChains( + ctx context.Context, + cfg config.Config, + mgr *manager.ClientManager, + alreadyConnected []string, +) ([]string, error) { + probed := make(map[string]struct{}, len(alreadyConnected)) + connected := make([]string, 0, len(cfg.Chains)) + for _, id := range alreadyConnected { + probed[id] = struct{}{} + connected = append(connected, id) + } + + for _, chain := range cfg.Chains { + if _, ok := probed[chain.ChainID]; ok { + continue + } + + client, err := mgr.GetClient(chain.ChainID) + if err != nil { + return nil, errors.Wrapf(err, "no chain client for chain %q", chain.ChainID) + } + + prober, ok := client.(chainIDProber) + if !ok { + return nil, errors.Errorf("chain %q client does not support a liveness probe", chain.ChainID) + } + + probeCtx, cancel := context.WithTimeout(ctx, chainProbeTimeout) + _, err = prober.ChainID(probeCtx) + cancel() + + if err != nil { + return nil, errors.Wrapf(err, "chain %q rpc unreachable", chain.ChainID) + } + + connected = append(connected, chain.ChainID) + } + + return connected, nil +} + +// printReadiness writes exactly one readiness JSON line to stdout. +func printReadiness(app *bootstrap.Services, chainsConnected []string) error { + readiness := server.Readiness{ + Event: server.ReadinessEvent, + ConfigLoaded: true, + DBReady: true, + ChainsConnected: chainsConnected, + // An engine-less daemon (zero-client config) serves RPC/health but relays + // nothing. + RelayerSubscribed: app.Engine != nil, + Status: server.ReadinessStatus{HTTP: app.Server.Addr()}, + } + + bz, err := json.Marshal(readiness) + if err != nil { + return err + } + + _, err = fmt.Fprintln(os.Stdout, string(bz)) + + return err +} + +// startRelayLoops starts the background relay loops present on the app (relay +// engine, auto-relay monitor, gas monitor) under a cancellable context and +// returns a shutdown callback that cancels them and waits for every loop to +// return. +func startRelayLoops(app *bootstrap.Services) graceful.ShutdownFunc { + ctx, cancel := context.WithCancel(app.Context) + + var wg sync.WaitGroup + + if app.Engine != nil { + app.Logger.Info("Starting relay engine") + wg.Add(1) + go func() { + defer wg.Done() + if err := app.Engine.Run(ctx); err != nil { + app.Logger.Error("Relay engine stopped with error", "err", err) + } + }() + } + + if app.AutoRelay != nil { + app.Logger.Info("Starting auto-relay monitor") + wg.Add(1) + go func() { + defer wg.Done() + if err := app.AutoRelay.Run(ctx); err != nil { + app.Logger.Error("Auto-relay monitor stopped with error", "err", err) + } + }() + } + + if app.GasMonitor != nil { + app.Logger.Info("Starting gas monitor") + wg.Add(1) + go func() { + defer wg.Done() + app.GasMonitor.Run(ctx) + }() + } + + return func() error { + cancel() + wg.Wait() + + return nil + } +} diff --git a/link/cmd/ibc/status.go b/link/cmd/ibc/status.go new file mode 100644 index 000000000..070005dc0 --- /dev/null +++ b/link/cmd/ibc/status.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" + "github.com/cosmos/ibc/link/internal/service/status" + "github.com/cosmos/ibc/link/internal/store" +) + +// flagStatusStuckAfter is the age past which a non-terminal packet is reported +// in the stuck list. Set in init(). +var flagStatusStuckAfter time.Duration + +// flagStatusAPIAddr, when set, fetches the operator view from a running daemon +// over HTTP instead of reading the shared DB directly. Set in init(). +var flagStatusAPIAddr string + +var cmdStatus = &cobra.Command{ + Use: "status [client-alias]", + Short: "Report connection, packet, retry, and quorum status per route", + Long: "Report per-route operator status: connection state (both chains), packet " + + "counts by state with a stuck list, retries per packet, and attestor quorum.\n\n" + + "By default status reads the shared relayer database directly and probes chains " + + "and attestors over the network, so it can run without a live daemon; it reports " + + "unreachable endpoints as state rather than failing. Pass --api-addr to fetch the " + + "same report from a running daemon's HTTP API instead (GET /status/operator).\n\n" + + "Pass a client alias to scope to one route, or omit it for every configured route.", + Args: cobra.MaximumNArgs(1), + RunE: statusRun, +} + +func statusRun(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + var alias string + if len(args) == 1 { + alias = args[0] + } + + // The API path talks to a running daemon and needs no local config/home, so it + // runs before setupHomeWithConfig. + if flagStatusAPIAddr != "" { + report, err := fetchOperatorStatus(ctx, flagStatusAPIAddr, alias, flagStatusStuckAfter) + if err != nil { + return err + } + + return config.PrintJSON(report) + } + + cfg, err := setupHomeWithConfig() + if err != nil { + return exitcode.New(exitcode.ConfigInvalid, err) + } + + // Opening the store pings the database; a failure here means the shared DB is + // not ready for reads. + db, err := store.NewStore(ctx, cfg) + if err != nil { + return exitcode.New(exitcode.NotReady, errors.Wrap(err, "opening store")) + } + defer db.Close() //nolint:errcheck + + // Zero-value probers apply DefaultChainTimeout / DefaultAttestorTimeout. + report, err := status.Assemble(ctx, cfg, db, + status.EVMProber{}, + status.RemoteAttestorProber{}, + status.Options{ + StuckAfter: flagStatusStuckAfter, + Alias: alias, + }, + ) + if err != nil { + return exitcode.New(exitcode.Internal, err) + } + + return config.PrintJSON(report) +} + +// statusFetchTimeout bounds the HTTP fetch from a running daemon so a socket that +// accepts but never answers cannot hang the CLI (cf. evm rpcRequestTimeout). +const statusFetchTimeout = 10 * time.Second + +// fetchOperatorStatus retrieves the operator status report from a running +// daemon's HTTP API (GET /status/operator), returning the same status.Report the +// direct-DB path assembles. Errors carry a sysexits-style exit code. +func fetchOperatorStatus( + ctx context.Context, + apiAddr, alias string, + stuckAfter time.Duration, +) (status.Report, error) { + u, err := operatorStatusURL(apiAddr, alias, stuckAfter) + if err != nil { + return status.Report{}, exitcode.New(exitcode.ConfigInvalid, errors.Wrap(err, "invalid --api-addr")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return status.Report{}, exitcode.New(exitcode.Internal, err) + } + + resp, err := (&http.Client{Timeout: statusFetchTimeout}).Do(req) + if err != nil { + return status.Report{}, exitcode.New( + exitcode.RPCUnreachable, + errors.Wrapf(err, "fetching status from %s", apiAddr), + ) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return status.Report{}, exitcode.New(exitcode.Internal, + errors.Errorf("daemon returned %s: %s", resp.Status, strings.TrimSpace(string(body)))) + } + + var report status.Report + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + return status.Report{}, exitcode.New(exitcode.Internal, errors.Wrap(err, "decoding status response")) + } + + return report, nil +} + +// operatorStatusURL builds the daemon's /status/operator URL from apiAddr, +// defaulting to the http scheme when apiAddr is a bare host:port. The query +// params mirror the server's statusQuery{Alias,StuckAfter} contract. +func operatorStatusURL(apiAddr, alias string, stuckAfter time.Duration) (string, error) { + base := apiAddr + if !strings.Contains(base, "://") { + base = "http://" + base + } + + u, err := url.Parse(base) + if err != nil { + return "", err + } + if u.Host == "" { + return "", errors.Errorf("no host in %q", apiAddr) + } + + u.Path = "/status/operator" + + q := url.Values{} + if alias != "" { + q.Set("alias", alias) + } + if stuckAfter > 0 { + q.Set("stuck-after", stuckAfter.String()) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} diff --git a/link/cmd/ibc/status_test.go b/link/cmd/ibc/status_test.go new file mode 100644 index 000000000..db40ab6ef --- /dev/null +++ b/link/cmd/ibc/status_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/exitcode" + "github.com/cosmos/ibc/link/internal/service/status" +) + +// minimalStatusConfig is structurally valid with no relay routes: `ibc status` +// must load it, open the sqlite store, and emit an empty-routes report. It +// exercises the command wiring and exit-code path without a live daemon. +const minimalStatusConfig = `server: + listenAddr: 127.0.0.1:8080 +db: + type: sqlite + url: ibc.db +chains: + - chainId: "1" + evm: + rpc: http://127.0.0.1:1 + ics26Router: "0x0000000000000000000000000000000000000001" +` + +// captureStdout redirects os.Stdout for the duration of fn and returns what was +// written. PrintJSON writes the status document to stdout via fmt.Println. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + + require.NoError(t, w.Close()) + os.Stdout = orig + + return <-done +} + +func TestStatusRunEmptyRoutes(t *testing.T) { + withHome(t, minimalStatusConfig) + + var runErr error + out := captureStdout(t, func() { + runErr = statusRun(cmdStatus, nil) + }) + require.NoError(t, runErr) + + var report struct { + GeneratedAt string `json:"generatedAt"` + StuckAfter string `json:"stuckAfter"` + Routes []any `json:"routes"` + } + require.NoError(t, json.Unmarshal([]byte(out), &report), "status must emit valid JSON: %s", out) + + assert.NotEmpty(t, report.GeneratedAt) + assert.NotEmpty(t, report.StuckAfter) + assert.Empty(t, report.Routes) +} + +// withEmptyHome points the CLI home at an empty directory with no config file, +// so a test fails if the code path under test ever requires a local config. +func withEmptyHome(t *testing.T) { + t.Helper() + + prev := globalFlags + globalFlags = config.DefaultFlagSet() + globalFlags.Home = t.TempDir() + globalFlags.Quiet = true + t.Cleanup(func() { globalFlags = prev }) +} + +// TestStatusRunAPIAddr drives the --api-addr fetch path against an httptest +// daemon: the CLI must GET /status/operator with the alias and stuck-after query +// params and render the report the daemon returns. The home is empty (no +// config), proving API mode needs none: the remote daemon owns the DB. +func TestStatusRunAPIAddr(t *testing.T) { + withEmptyHome(t) + + want := status.Report{ + GeneratedAt: time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC), + StuckAfter: "3m0s", + Routes: []status.RouteStatus{{ + Alias: "route-a", + ChainID: "1", + ClientID: "client-0", + Packets: status.PacketsStatus{Total: 1, ByState: map[string]int{"pending": 1}}, + }}, + } + + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(want) + })) + defer srv.Close() + + prevAddr, prevStuck := flagStatusAPIAddr, flagStatusStuckAfter + flagStatusAPIAddr = srv.URL + flagStatusStuckAfter = 3 * time.Minute + t.Cleanup(func() { flagStatusAPIAddr, flagStatusStuckAfter = prevAddr, prevStuck }) + + var runErr error + out := captureStdout(t, func() { + runErr = statusRun(cmdStatus, []string{"route-a"}) + }) + require.NoError(t, runErr) + + assert.Equal(t, "/status/operator", gotPath) + assert.Contains(t, gotQuery, "alias=route-a") + assert.Contains(t, gotQuery, "stuck-after=3m0s") + + var got status.Report + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, want, got) +} + +// TestStatusRunAPIAddrUnreachable checks a dial failure maps to the +// RPC-unreachable exit code rather than a raw error. +func TestStatusRunAPIAddrUnreachable(t *testing.T) { + withEmptyHome(t) + + prevAddr := flagStatusAPIAddr + // Port 1 on loopback refuses connections. + flagStatusAPIAddr = "127.0.0.1:1" + t.Cleanup(func() { flagStatusAPIAddr = prevAddr }) + + err := statusRun(cmdStatus, nil) + require.Error(t, err) + + var ec exitcode.Error + require.ErrorAs(t, err, &ec) + assert.Equal(t, exitcode.RPCUnreachable, ec.Code) +} + +func TestVersionRun(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, versionRun(cmdVersion, nil)) + }) + + var v map[string]string + require.NoError(t, json.Unmarshal([]byte(out), &v)) + + // Defaults when not stamped by ldflags. + assert.Equal(t, "dev", v["version"]) + assert.Contains(t, v, "commit") + assert.Contains(t, v, "date") +} diff --git a/link/cmd/ibc/version.go b/link/cmd/ibc/version.go new file mode 100644 index 000000000..1b3c55519 --- /dev/null +++ b/link/cmd/ibc/version.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/cosmos/ibc/link/internal/config" +) + +// Build metadata, overridable at link time via -X ldflags (see link/Makefile). +// Defaults keep an un-stamped `go build` / `go run` honest with "dev". +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +var cmdVersion = &cobra.Command{ + Use: "version", + Short: "Print version, commit, and build date", + Args: cobra.NoArgs, + RunE: versionRun, +} + +func versionRun(_ *cobra.Command, _ []string) error { + return config.PrintJSON(map[string]string{ + "version": version, + "commit": commit, + "date": date, + }) +} diff --git a/link/go.mod b/link/go.mod index fe897dd96..7bf6ce933 100644 --- a/link/go.mod +++ b/link/go.mod @@ -4,19 +4,30 @@ go 1.26.4 require ( connectrpc.com/connect v1.20.0 + connectrpc.com/grpchealth v1.5.0 connectrpc.com/grpcreflect v1.3.0 + connectrpc.com/otelconnect v0.9.0 github.com/cometbft/cometbft v0.39.0-rc1.0.20260615134937-9ea34470f336 github.com/cosmos/kms v0.0.0-20260709100357-9eeae77b051e + github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 + github.com/ethereum/go-ethereum v1.17.4 github.com/goccy/go-yaml v1.19.2 github.com/jackc/pgx/v5 v5.10.0 github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.23.2 github.com/rubenv/sql-migrate v1.8.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.43.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + golang.org/x/sync v0.21.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 ) @@ -25,26 +36,36 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cockroachdb/errors v1.11.3 // indirect github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect - github.com/cockroachdb/pebble v1.1.1 // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect github.com/cockroachdb/redact v1.1.5 // indirect github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect github.com/cometbft/cometbft-db v0.14.1 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cosmos/gogoproto v1.7.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/dchest/siphash v1.2.3 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -53,8 +74,14 @@ require ( github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/emicklei/dot v1.6.2 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/ferranbt/fastssz v0.1.4 // indirect + github.com/fjl/jsonw v0.1.0 // indirect github.com/flynn/noise v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-kit/kit v0.13.0 // indirect @@ -62,18 +89,27 @@ require ( github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/glog v1.2.5 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v1.12.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grafana/pyroscope-go v1.2.7 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/go-bexpr v0.1.10 // indirect + github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db // indirect + github.com/holiman/bloomfilter/v2 v2.0.3 // indirect + github.com/holiman/uint256 v1.3.2 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ipfs/go-cid v0.5.0 // indirect @@ -88,6 +124,7 @@ require ( github.com/koron/go-ssdp v0.0.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-flow-metrics v0.2.0 // indirect github.com/libp2p/go-libp2p v0.47.0 // indirect @@ -100,12 +137,15 @@ require ( github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/miekg/dns v1.1.66 // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/pointerstructure v1.2.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.2 // indirect @@ -135,7 +175,7 @@ require ( github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/dtls/v3 v3.0.11 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/ice/v4 v4.0.10 // indirect github.com/pion/interceptor v0.1.40 // indirect github.com/pion/logging v0.2.4 // indirect @@ -147,7 +187,7 @@ require ( github.com/pion/sdp/v3 v3.0.13 // indirect github.com/pion/srtp/v3 v3.0.6 // indirect github.com/pion/stun v0.6.1 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/stun/v3 v3.1.2 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/transport/v4 v4.0.1 // indirect @@ -155,7 +195,6 @@ require ( github.com/pion/webrtc/v4 v4.1.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -164,7 +203,10 @@ require ( github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sasha-s/go-deadlock v0.3.9 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect @@ -174,15 +216,18 @@ require ( github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/urfave/cli/v2 v2.27.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.0-alpha.0.0.20240404170359-43604f3112c5 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace 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/proto/otlp v1.10.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/fx v1.24.0 // indirect go.uber.org/mock v0.5.2 // indirect @@ -192,15 +237,15 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.45.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.81.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260713224248-f5fc221cf8c4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/link/go.sum b/link/go.sum index ddf91e253..94a97baf3 100644 --- a/link/go.sum +++ b/link/go.sum @@ -1,8 +1,12 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +connectrpc.com/grpchealth v1.5.0 h1:nHEVTwz9WYKxW2JTYUFD337q76oAZMvot9jX0WjVCQo= +connectrpc.com/grpchealth v1.5.0/go.mod h1:fC9WGwKmDruKCNh8wj2rThiaxxoiXxvsCVIu2Ex2voA= connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= +connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= +connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -16,17 +20,31 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -42,8 +60,8 @@ github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/e github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= -github.com/cockroachdb/pebble v1.1.1 h1:XnKU22oiCLy2Xn8vp1re67cXg4SAasg/WDt1NtcRFaw= -github.com/cockroachdb/pebble v1.1.1/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= @@ -52,6 +70,8 @@ github.com/cometbft/cometbft v0.39.0-rc1.0.20260615134937-9ea34470f336 h1:Nyc8hg github.com/cometbft/cometbft v0.39.0-rc1.0.20260615134937-9ea34470f336/go.mod h1:JxBvpWV9MU+ZqHdZaZmuFfrqgqyMGV8AYJWhJ/nPPh4= github.com/cometbft/cometbft-db v0.14.1 h1:SxoamPghqICBAIcGpleHbmoPqy+crij/++eZz3DlerQ= github.com/cometbft/cometbft-db v0.14.1/go.mod h1:KHP1YghilyGV/xjD5DP3+2hyigWx0WTp9X+0Gnx0RxQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -64,9 +84,14 @@ github.com/cosmos/gogoproto v1.7.2 h1:5G25McIraOC0mRFv9TVO139Uh3OklV2hczr13KKVHC github.com/cosmos/gogoproto v1.7.2/go.mod h1:8S7w53P1Y1cHwND64o0BnArT6RmdgIvsBuco6uTllsk= github.com/cosmos/kms v0.0.0-20260709100357-9eeae77b051e h1:PcYIIjwEN8bvnOvK2GjYKNEEQ9Qul27DBnfzFEBOxn8= github.com/cosmos/kms v0.0.0-20260709100357-9eeae77b051e/go.mod h1:TaVPj19tphn9vL/420FWMKx19MHgL+nxOf0DiCkZAn4= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b h1:wst+2QsydHKoplWYY7V07OqxKyCmL59hjFsDLQoqZ5I= +github.com/cosmos/solidity-ibc-eureka/packages/go-abigen v0.0.0-20260618122836-39904319467b/go.mod h1:ndpS//nq2Ghin4UFdp0Cg4lrHZwK1VS/QRmi6DL9H+g= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -76,10 +101,16 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= @@ -99,12 +130,24 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +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.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +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.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= 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= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -113,6 +156,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -130,14 +175,20 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -157,8 +208,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= @@ -172,6 +224,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -179,14 +233,36 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= +github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k= +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= +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/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -218,6 +294,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= @@ -248,8 +328,13 @@ github.com/marcopolo/simnet v0.0.4 h1:50Kx4hS9kFGSRIbrt9xUS3NJX33EyPqHVmpXvaKLqr github.com/marcopolo/simnet v0.0.4/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -266,6 +351,11 @@ github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8Rv github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -330,8 +420,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +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/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -341,8 +435,8 @@ github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oL github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/dtls/v3 v3.0.11 h1:zqn8YhoAU7d9whsWLhNiQlbB8QdpJj8XQVSc5ImUons= -github.com/pion/dtls/v3 v3.0.11/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= @@ -366,8 +460,8 @@ github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= @@ -399,6 +493,8 @@ github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pS github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= +github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= @@ -407,14 +503,21 @@ github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+ github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w= github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -453,9 +556,13 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -471,6 +578,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 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/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= 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= @@ -479,6 +590,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC 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/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= @@ -570,7 +683,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -625,8 +740,10 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -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-20260713224248-f5fc221cf8c4 h1:lI0NbdWVmT6lOJJNDd7vyeTdfxP/7ouCLSJUKNNXa0k= +google.golang.org/genproto/googleapis/api v0.0.0-20260713224248-f5fc221cf8c4/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -649,6 +766,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/link/internal/attestation/abi.go b/link/internal/attestation/abi.go new file mode 100644 index 000000000..40277a88a --- /dev/null +++ b/link/internal/attestation/abi.go @@ -0,0 +1,234 @@ +// Package attestation provides pure functions for building, hashing, ABI-encoding +// and signing IBC attestations, mirroring the on-chain Solidity semantics exactly. +// It performs no I/O and holds no chain clients. +package attestation + +import ( + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" +) + +// PacketCompact mirrors Solidity PacketCompact{bytes32 path; bytes32 commitment}. +type PacketCompact struct { + Path [32]byte `abi:"path"` + Commitment [32]byte `abi:"commitment"` +} + +// ABI marshaling mirrors of the Solidity structs; fields carry abi tags so +// go-ethereum maps them to the tuple components by name. +type ( + abiPayload struct { + SourcePort string `abi:"sourcePort"` + DestPort string `abi:"destPort"` + Version string `abi:"version"` + Encoding string `abi:"encoding"` + Value []byte `abi:"value"` + } + + abiPacket struct { + Sequence uint64 `abi:"sequence"` + SourceClient string `abi:"sourceClient"` + DestClient string `abi:"destClient"` + TimeoutTimestamp uint64 `abi:"timeoutTimestamp"` + Payloads []abiPayload `abi:"payloads"` + } + + abiStateAttestation struct { + Height uint64 `abi:"height"` + Timestamp uint64 `abi:"timestamp"` + } + + abiPacketAttestation struct { + Height uint64 `abi:"height"` + Packets []PacketCompact `abi:"packets"` + } + + abiAttestationProof struct { + AttestationData []byte `abi:"attestationData"` + Signatures [][]byte `abi:"signatures"` + } +) + +// ABI primitive type names. +const ( + abiString = "string" + abiUint64 = "uint64" + abiBytes = "bytes" + abiBytes32 = "bytes32" +) + +func mustNewTuple(components []abi.ArgumentMarshaling) abi.Type { + typ, err := abi.NewType("tuple", "", components) + if err != nil { + panic(err) // static type definitions, cannot fail at runtime + } + return typ +} + +var payloadComponents = []abi.ArgumentMarshaling{ + {Name: "sourcePort", Type: abiString}, + {Name: "destPort", Type: abiString}, + {Name: "version", Type: abiString}, + {Name: "encoding", Type: abiString}, + {Name: "value", Type: abiBytes}, +} + +var packetCompactComponents = []abi.ArgumentMarshaling{ + {Name: "path", Type: abiBytes32}, + {Name: "commitment", Type: abiBytes32}, +} + +// One implicit tuple wrapping per struct == Solidity abi.encode(struct). +var ( + packetArgs = abi.Arguments{{Type: mustNewTuple([]abi.ArgumentMarshaling{ + {Name: "sequence", Type: abiUint64}, + {Name: "sourceClient", Type: abiString}, + {Name: "destClient", Type: abiString}, + {Name: "timeoutTimestamp", Type: abiUint64}, + {Name: "payloads", Type: "tuple[]", Components: payloadComponents}, + })}} + + stateAttestationArgs = abi.Arguments{{Type: mustNewTuple([]abi.ArgumentMarshaling{ + {Name: "height", Type: abiUint64}, + {Name: "timestamp", Type: abiUint64}, + })}} + + packetAttestationArgs = abi.Arguments{{Type: mustNewTuple([]abi.ArgumentMarshaling{ + {Name: "height", Type: abiUint64}, + {Name: "packets", Type: "tuple[]", Components: packetCompactComponents}, + })}} + + attestationProofArgs = abi.Arguments{{Type: mustNewTuple([]abi.ArgumentMarshaling{ + {Name: "attestationData", Type: abiBytes}, + {Name: "signatures", Type: "bytes[]"}, + })}} +) + +// EncodePacket ABI-encodes a single packet as Solidity abi.encode(IICS26RouterMsgs.Packet). +func EncodePacket(p chains.Packet) ([]byte, error) { + data, err := packetArgs.Pack(toABIPacket(p)) + if err != nil { + return nil, errors.Wrap(err, "packing packet") + } + return data, nil +} + +// DecodePacket decodes bytes produced by EncodePacket. +func DecodePacket(data []byte) (chains.Packet, error) { + out, err := decodeTuple[abiPacket](packetArgs, data) + if err != nil { + return chains.Packet{}, errors.Wrap(err, "decoding packet") + } + return fromABIPacket(out), nil +} + +// EncodeStateAttestation ABI-encodes StateAttestation{height, timestamp}: 64 static bytes, no offset. +func EncodeStateAttestation(height, timestamp uint64) []byte { + data, err := stateAttestationArgs.Pack(abiStateAttestation{Height: height, Timestamp: timestamp}) + if err != nil { + panic(err) // static tuple of uint64s, cannot fail + } + return data +} + +// DecodeStateAttestation decodes bytes produced by EncodeStateAttestation. +func DecodeStateAttestation(data []byte) (height, timestamp uint64, err error) { + out, err := decodeTuple[abiStateAttestation](stateAttestationArgs, data) + if err != nil { + return 0, 0, errors.Wrap(err, "decoding state attestation") + } + return out.Height, out.Timestamp, nil +} + +// EncodePacketAttestation ABI-encodes PacketAttestation{height, packets}: dynamic, leading 0x20 offset. +func EncodePacketAttestation(height uint64, packets []PacketCompact) ([]byte, error) { + data, err := packetAttestationArgs.Pack(abiPacketAttestation{Height: height, Packets: packets}) + if err != nil { + return nil, errors.Wrap(err, "packing packet attestation") + } + return data, nil +} + +// DecodePacketAttestation decodes bytes produced by EncodePacketAttestation. +func DecodePacketAttestation(data []byte) (uint64, []PacketCompact, error) { + out, err := decodeTuple[abiPacketAttestation](packetAttestationArgs, data) + if err != nil { + return 0, nil, errors.Wrap(err, "decoding packet attestation") + } + return out.Height, out.Packets, nil +} + +// EncodeAttestationProof ABI-encodes AttestationProof{attestationData, signatures}: the updateMsg / +// proofCommitment / proofAcked / proofTimeout blob. +func EncodeAttestationProof(attestedData []byte, signatures [][]byte) ([]byte, error) { + data, err := attestationProofArgs.Pack(abiAttestationProof{AttestationData: attestedData, Signatures: signatures}) + if err != nil { + return nil, errors.Wrap(err, "packing attestation proof") + } + return data, nil +} + +// DecodeAttestationProof decodes bytes produced by EncodeAttestationProof. +func DecodeAttestationProof(data []byte) (attestedData []byte, signatures [][]byte, err error) { + out, err := decodeTuple[abiAttestationProof](attestationProofArgs, data) + if err != nil { + return nil, nil, errors.Wrap(err, "decoding attestation proof") + } + return out.AttestationData, out.Signatures, nil +} + +// decodeTuple unpacks a single implicit-tuple argument into T. go-ethereum's Copy assigns +// the sole value into the first field of the destination struct, so T is wrapped. +func decodeTuple[T any](args abi.Arguments, data []byte) (T, error) { + var wrap struct{ V T } + values, err := args.Unpack(data) + if err != nil { + return wrap.V, errors.Wrap(err, "unpacking") + } + if err := args.Copy(&wrap, values); err != nil { + return wrap.V, errors.Wrap(err, "copying") + } + return wrap.V, nil +} + +func toABIPacket(p chains.Packet) abiPacket { + payloads := make([]abiPayload, len(p.Payloads)) + for i, payload := range p.Payloads { + payloads[i] = abiPayload{ + SourcePort: payload.SourcePort, + DestPort: payload.DestPort, + Version: payload.Version, + Encoding: payload.Encoding, + Value: payload.Value, + } + } + return abiPacket{ + Sequence: p.Sequence, + SourceClient: p.SourceClient, + DestClient: p.DestClient, + TimeoutTimestamp: p.TimeoutTimestamp, + Payloads: payloads, + } +} + +func fromABIPacket(p abiPacket) chains.Packet { + payloads := make([]chains.Payload, len(p.Payloads)) + for i, payload := range p.Payloads { + payloads[i] = chains.Payload{ + SourcePort: payload.SourcePort, + DestPort: payload.DestPort, + Version: payload.Version, + Encoding: payload.Encoding, + Value: payload.Value, + } + } + return chains.Packet{ + Sequence: p.Sequence, + SourceClient: p.SourceClient, + DestClient: p.DestClient, + TimeoutTimestamp: p.TimeoutTimestamp, + Payloads: payloads, + } +} diff --git a/link/internal/attestation/abi_test.go b/link/internal/attestation/abi_test.go new file mode 100644 index 000000000..36bb4d1d2 --- /dev/null +++ b/link/internal/attestation/abi_test.go @@ -0,0 +1,211 @@ +package attestation + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" +) + +func word(v uint64) []byte { + b := make([]byte, 32) + binary.BigEndian.PutUint64(b[24:], v) + return b +} + +func fullPacket() chains.Packet { + return chains.Packet{ + Sequence: 42, + SourceClient: "base-0", + DestClient: "ethereum-0", + TimeoutTimestamp: 1780000000, + Payloads: []chains.Payload{ + { + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte{0xde, 0xad, 0xbe, 0xef}, + }, + }, + } +} + +func TestPacketRoundTrip(t *testing.T) { + tests := []struct { + name string + packet chains.Packet + }{ + {"single-payload", fullPacket()}, + { + "multi-payload", + chains.Packet{ + Sequence: 1, + SourceClient: "src", + DestClient: "dst", + TimeoutTimestamp: 5, + Payloads: []chains.Payload{ + {SourcePort: "a", DestPort: "b", Version: "v", Encoding: "e", Value: []byte{0x01}}, + {SourcePort: "c", DestPort: "d", Version: "w", Encoding: "f", Value: []byte{0x02, 0x03}}, + }, + }, + }, + { + "empty-value", + chains.Packet{ + Sequence: 0, + SourceClient: "", + DestClient: "", + TimeoutTimestamp: 0, + Payloads: []chains.Payload{ + {SourcePort: "", DestPort: "", Version: "", Encoding: "", Value: []byte{}}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + encoded, err := EncodePacket(tc.packet) + require.NoError(t, err) + + // A dynamic struct starts with the 0x20 offset word. + assert.Equal(t, word(0x20), encoded[:32]) + + decoded, err := DecodePacket(encoded) + require.NoError(t, err) + assert.Equal(t, tc.packet, decoded) + }) + } +} + +func TestStateAttestationLayout(t *testing.T) { + encoded := EncodeStateAttestation(1, 2) + + // Static: exactly 64 bytes, no offset prefix. + require.Len(t, encoded, 64) + assert.Equal(t, word(1), encoded[:32]) + assert.Equal(t, word(2), encoded[32:64]) + + height, timestamp, err := DecodeStateAttestation(encoded) + require.NoError(t, err) + assert.Equal(t, uint64(1), height) + assert.Equal(t, uint64(2), timestamp) +} + +func TestStateAttestationRoundTrip(t *testing.T) { + cases := [][2]uint64{{0, 0}, {1, 2}, {1 << 40, 1 << 50}} + for _, c := range cases { + encoded := EncodeStateAttestation(c[0], c[1]) + h, ts, err := DecodeStateAttestation(encoded) + require.NoError(t, err) + assert.Equal(t, c[0], h) + assert.Equal(t, c[1], ts) + } +} + +func TestPacketAttestationLayout(t *testing.T) { + path := [32]byte{0xaa} + commitment := [32]byte{0xbb} + packets := []PacketCompact{{Path: path, Commitment: commitment}} + + encoded, err := EncodePacketAttestation(7, packets) + require.NoError(t, err) + + // Dynamic struct: [0x20, height, 0x40, len, path, commitment]. + require.Len(t, encoded, 6*32) + assert.Equal(t, word(0x20), encoded[0:32]) + assert.Equal(t, word(7), encoded[32:64]) + assert.Equal(t, word(0x40), encoded[64:96]) + assert.Equal(t, word(1), encoded[96:128]) + assert.Equal(t, path[:], encoded[128:160]) + assert.Equal(t, commitment[:], encoded[160:192]) + + height, gotPackets, err := DecodePacketAttestation(encoded) + require.NoError(t, err) + assert.Equal(t, uint64(7), height) + assert.Equal(t, packets, gotPackets) +} + +func TestPacketAttestationEmpty(t *testing.T) { + encoded, err := EncodePacketAttestation(3, nil) + require.NoError(t, err) + + height, packets, err := DecodePacketAttestation(encoded) + require.NoError(t, err) + assert.Equal(t, uint64(3), height) + assert.Empty(t, packets) +} + +func TestAttestationProofLayout(t *testing.T) { + sig1 := bytes.Repeat([]byte{0x11}, 65) + sig2 := bytes.Repeat([]byte{0x22}, 65) + attData := []byte{0xde, 0xad} + + encoded, err := EncodeAttestationProof(attData, [][]byte{sig1, sig2}) + require.NoError(t, err) + + // Outer tuple offset, then head [offset-attData, offset-signatures]. + assert.Equal(t, word(0x20), encoded[0:32]) + assert.Equal(t, word(0x40), encoded[32:64]) // attestationData offset within tuple + assert.Equal(t, word(0x80), encoded[64:96]) // signatures offset within tuple + assert.Equal(t, word(2), encoded[96:128]) // attestationData length + assert.Equal(t, attData, encoded[128:130]) // attestationData bytes + assert.Equal(t, word(2), encoded[160:192]) // signatures array length + assert.Equal(t, word(0x40), encoded[192:224]) // offset to sig0 (relative to array data) + assert.Equal(t, word(0xc0), encoded[224:256]) // offset to sig1 + assert.Equal(t, word(65), encoded[256:288]) // sig0 length (0x41) + assert.Equal(t, sig1, encoded[288:353]) // 65 bytes + // sig0 padded to 96: bytes 353..384 are zero padding. + assert.Equal(t, make([]byte, 31), encoded[353:384]) + assert.Equal(t, word(65), encoded[384:416]) // sig1 length + assert.Equal(t, sig2, encoded[416:481]) + + gotData, gotSigs, err := DecodeAttestationProof(encoded) + require.NoError(t, err) + assert.Equal(t, attData, gotData) + require.Len(t, gotSigs, 2) + assert.Equal(t, sig1, gotSigs[0]) + assert.Equal(t, sig2, gotSigs[1]) +} + +func TestAttestationProofRoundTrip(t *testing.T) { + tests := []struct { + name string + attData []byte + sigs [][]byte + }{ + {"empty", []byte{}, nil}, + {"one-sig", []byte{0x01, 0x02}, [][]byte{bytes.Repeat([]byte{0x11}, 65)}}, + {"three-sigs", []byte("state"), [][]byte{ + bytes.Repeat([]byte{0x11}, 65), + bytes.Repeat([]byte{0x22}, 65), + bytes.Repeat([]byte{0x33}, 65), + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + encoded, err := EncodeAttestationProof(tc.attData, tc.sigs) + require.NoError(t, err) + assert.Equal(t, word(0x20), encoded[:32]) + + gotData, gotSigs, err := DecodeAttestationProof(encoded) + require.NoError(t, err) + assert.Equal(t, tc.attData, gotData) + assert.Equal(t, len(tc.sigs), len(gotSigs)) + for i := range tc.sigs { + assert.Equal(t, tc.sigs[i], gotSigs[i]) + } + }) + } +} + +func TestDecodePacketError(t *testing.T) { + _, err := DecodePacket([]byte{0x01, 0x02}) + require.Error(t, err) +} diff --git a/link/internal/attestation/commitment.go b/link/internal/attestation/commitment.go new file mode 100644 index 000000000..a1d0f777a --- /dev/null +++ b/link/internal/attestation/commitment.go @@ -0,0 +1,44 @@ +package attestation + +import ( + "crypto/sha256" + + "github.com/cosmos/ibc/link/internal/chains" +) + +// payloadHash = sha256(sha256(srcPort)||sha256(dstPort)||sha256(version)||sha256(encoding)||sha256(value)). +func payloadHash(p chains.Payload) [32]byte { + sourcePort := sha256.Sum256([]byte(p.SourcePort)) + destPort := sha256.Sum256([]byte(p.DestPort)) + version := sha256.Sum256([]byte(p.Version)) + encoding := sha256.Sum256([]byte(p.Encoding)) + value := sha256.Sum256(p.Value) + + concat := make([]byte, 0, 32*5) + concat = append(concat, sourcePort[:]...) + concat = append(concat, destPort[:]...) + concat = append(concat, version[:]...) + concat = append(concat, encoding[:]...) + concat = append(concat, value[:]...) + return sha256.Sum256(concat) +} + +// PacketCommitment = sha256(0x02 || sha256(destClient) || sha256(be64(timeout)) || sha256(concat(payloadHash_i))). +func PacketCommitment(p chains.Packet) [32]byte { + destClient := sha256.Sum256([]byte(p.DestClient)) + timeout := sha256.Sum256(be64(p.TimeoutTimestamp)) + + payloadsConcat := make([]byte, 0, 32*len(p.Payloads)) + for i := range p.Payloads { + h := payloadHash(p.Payloads[i]) + payloadsConcat = append(payloadsConcat, h[:]...) + } + payloadsHash := sha256.Sum256(payloadsConcat) + + buf := make([]byte, 0, 1+32*3) + buf = append(buf, commitmentVersion) + buf = append(buf, destClient[:]...) + buf = append(buf, timeout[:]...) + buf = append(buf, payloadsHash[:]...) + return sha256.Sum256(buf) +} diff --git a/link/internal/attestation/commitment_test.go b/link/internal/attestation/commitment_test.go new file mode 100644 index 000000000..0e80fcacb --- /dev/null +++ b/link/internal/attestation/commitment_test.go @@ -0,0 +1,101 @@ +package attestation + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cosmos/ibc/link/internal/chains" +) + +// sha concatenates its args and sha256s them, used to recompute vectors independently. +func sha(parts ...[]byte) [32]byte { + h := sha256.New() + for _, p := range parts { + h.Write(p) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +func s(v string) []byte { return []byte(v) } + +func be8(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +func TestUniversalErrorAckKnownValue(t *testing.T) { + sum := sha256.Sum256([]byte("UNIVERSAL_ERROR_ACKNOWLEDGEMENT")) + assert.Equal(t, + "4774d4a575993f963b1c06573736617a457abef8589178db8d10c94b4ab511ab", + hex.EncodeToString(sum[:]), + ) +} + +func TestPacketCommitmentVector(t *testing.T) { + payload := chains.Payload{ + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte{0xde, 0xad, 0xbe, 0xef}, + } + packet := chains.Packet{ + Sequence: 42, + SourceClient: "base-0", + DestClient: "ethereum-0", + TimeoutTimestamp: 1780000000, + Payloads: []chains.Payload{payload}, + } + + // Independent recompute with explicit sha256 calls. + spH := sha(s(payload.SourcePort)) + dpH := sha(s(payload.DestPort)) + verH := sha(s(payload.Version)) + encH := sha(s(payload.Encoding)) + valH := sha(payload.Value) + wantPayloadHash := sha(spH[:], dpH[:], verH[:], encH[:], valH[:]) + + dcH := sha(s(packet.DestClient)) + tsH := sha(be8(packet.TimeoutTimestamp)) + payloadsH := sha(wantPayloadHash[:]) + wantCommitment := sha([]byte{0x02}, dcH[:], tsH[:], payloadsH[:]) + + assert.Equal(t, wantCommitment, PacketCommitment(packet)) +} + +func TestPacketCommitmentMultiPayload(t *testing.T) { + p1 := chains.Payload{SourcePort: "a", DestPort: "b", Version: "v1", Encoding: "e1", Value: []byte{0x01}} + p2 := chains.Payload{SourcePort: "c", DestPort: "d", Version: "v2", Encoding: "e2", Value: []byte{0x02, 0x03}} + packet := chains.Packet{ + Sequence: 7, + SourceClient: "src-0", + DestClient: "dst-0", + TimeoutTimestamp: 999, + Payloads: []chains.Payload{p1, p2}, + } + + ph := func(p chains.Payload) [32]byte { + sp := sha(s(p.SourcePort)) + dp := sha(s(p.DestPort)) + ve := sha(s(p.Version)) + en := sha(s(p.Encoding)) + va := sha(p.Value) + return sha(sp[:], dp[:], ve[:], en[:], va[:]) + } + ph1 := ph(p1) + ph2 := ph(p2) + + dcH := sha(s(packet.DestClient)) + tsH := sha(be8(packet.TimeoutTimestamp)) + payloadsH := sha(ph1[:], ph2[:]) + want := sha([]byte{0x02}, dcH[:], tsH[:], payloadsH[:]) + + assert.Equal(t, want, PacketCommitment(packet)) +} diff --git a/link/internal/attestation/paths.go b/link/internal/attestation/paths.go new file mode 100644 index 000000000..e32722a37 --- /dev/null +++ b/link/internal/attestation/paths.go @@ -0,0 +1,49 @@ +package attestation + +import ( + "encoding/binary" + + "github.com/ethereum/go-ethereum/crypto" +) + +// ICS24 path separator bytes and commitment version byte. +const ( + sepPacketCommitment byte = 0x01 + sepPacketReceipt byte = 0x02 + sepPacketAck byte = 0x03 + commitmentVersion byte = 0x02 // ICS24 commitment leaf version prefix +) + +func be64(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +// PacketCommitmentPath = utf8(sourceClient) || 0x01 || be64(sequence). +func PacketCommitmentPath(sourceClient string, sequence uint64) []byte { + return buildPath(sourceClient, sepPacketCommitment, sequence) +} + +// PacketReceiptPath = utf8(destClient) || 0x02 || be64(sequence). +func PacketReceiptPath(destClient string, sequence uint64) []byte { + return buildPath(destClient, sepPacketReceipt, sequence) +} + +// PacketAckCommitmentPath = utf8(destClient) || 0x03 || be64(sequence). +func PacketAckCommitmentPath(destClient string, sequence uint64) []byte { + return buildPath(destClient, sepPacketAck, sequence) +} + +func buildPath(client string, sep byte, sequence uint64) []byte { + out := make([]byte, 0, len(client)+1+8) + out = append(out, client...) + out = append(out, sep) + out = append(out, be64(sequence)...) + return out +} + +// PathHash = keccak256(rawPath); the on-chain getCommitment key and PacketCompact.path. +func PathHash(rawPath []byte) [32]byte { + return crypto.Keccak256Hash(rawPath) +} diff --git a/link/internal/attestation/paths_test.go b/link/internal/attestation/paths_test.go new file mode 100644 index 000000000..2cb56ae85 --- /dev/null +++ b/link/internal/attestation/paths_test.go @@ -0,0 +1,47 @@ +package attestation + +import ( + "encoding/binary" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" +) + +func be(seq uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, seq) + return b +} + +func TestPacketPaths(t *testing.T) { + client := "base-0" + seq := uint64(42) + seqBytes := be(seq) + + tests := []struct { + name string + got []byte + sepByte byte + }{ + {"commitment", PacketCommitmentPath(client, seq), 0x01}, + {"receipt", PacketReceiptPath(client, seq), 0x02}, + {"ack", PacketAckCommitmentPath(client, seq), 0x03}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + want := append([]byte(client), tc.sepByte) + want = append(want, seqBytes...) + assert.Equal(t, want, tc.got) + assert.Equal(t, len(client)+1+8, len(tc.got)) + }) + } +} + +func TestPathHash(t *testing.T) { + raw := PacketCommitmentPath("base-0", 42) + want := crypto.Keccak256(raw) + got := PathHash(raw) + assert.Equal(t, want, got[:]) +} diff --git a/link/internal/attestation/signing.go b/link/internal/attestation/signing.go new file mode 100644 index 000000000..f3b0071de --- /dev/null +++ b/link/internal/attestation/signing.go @@ -0,0 +1,78 @@ +package attestation + +import ( + "crypto/sha256" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/pkg/errors" +) + +// Attestation type tags fed into the signing digest. +const ( + TypeState byte = 0x01 + TypePacket byte = 0x02 +) + +// Domain separation: the signing digest covers only the type tag and the attested +// data — no chain id, client id, or contract address — matching the deployed Solidity +// light client and the Rust attestor. An attestor identity shared across multiple +// attestor sets therefore makes same-(height,timestamp) proofs cross-acceptable +// between those clients; operators must use distinct attestor keys per client set +// where that matters. Adding domain separation would require an on-chain contract change. + +// SigningInput = tag || sha256(attestedData); the 33-byte value handed to a Signer, +// which sha256-hashes it again before ECDSA. +func SigningInput(tag byte, attestedData []byte) []byte { + sum := sha256.Sum256(attestedData) + out := make([]byte, 0, 1+len(sum)) + out = append(out, tag) + out = append(out, sum[:]...) + return out +} + +// Digest = sha256(SigningInput); the actual ECDSA prehash. +func Digest(tag byte, attestedData []byte) [32]byte { + return sha256.Sum256(SigningInput(tag, attestedData)) +} + +// NormalizeV converts a 65-byte signature's recovery id from go-ethereum's {0,1} +// to the on-chain required {27,28}; existing {27,28} pass through. +func NormalizeV(sig []byte) ([]byte, error) { + if len(sig) != 65 { + return nil, errors.Errorf("invalid signature length %d, expected 65", len(sig)) + } + out := make([]byte, 65) + copy(out, sig) + switch out[64] { + case 0, 1: + out[64] += 27 + case 27, 28: + default: + return nil, errors.Errorf("invalid signature v byte %d, expected 0, 1, 27 or 28", out[64]) + } + return out, nil +} + +// RecoverSigner recovers the signer address from a digest and 65-byte signature, +// accepting v in {0,1} or {27,28}. +func RecoverSigner(digest [32]byte, sig65 []byte) (common.Address, error) { + if len(sig65) != 65 { + return common.Address{}, errors.Errorf("invalid signature length %d, expected 65", len(sig65)) + } + sig := make([]byte, 65) + copy(sig, sig65) + switch sig[64] { + case 27, 28: + sig[64] -= 27 + case 0, 1: + default: + return common.Address{}, errors.Errorf("invalid signature v byte %d, expected 0, 1, 27 or 28", sig[64]) + } + + pub, err := crypto.SigToPub(digest[:], sig) + if err != nil { + return common.Address{}, errors.Wrap(err, "recovering public key") + } + return crypto.PubkeyToAddress(*pub), nil +} diff --git a/link/internal/attestation/signing_test.go b/link/internal/attestation/signing_test.go new file mode 100644 index 000000000..66bb117d4 --- /dev/null +++ b/link/internal/attestation/signing_test.go @@ -0,0 +1,128 @@ +package attestation + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Deterministic (RFC6979) test key and its derived address. +const ( + testPrivKey = "b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291" + testAddress = "0x71562b71999873DB5b286dF957af199Ec94617F7" + expectedNorm = "e56bf3af239c41a4e794af484166e4dfab6f252ce1f99dc84327210841eb8e6f" + + "6d6b0b8a4cac0a8cab8e6318b579fa142a3e202c8021f2c94d35318b74e8fa1e1c" +) + +func TestSigningInput(t *testing.T) { + data := []byte("attested") + got := SigningInput(TypeState, data) + + require.Len(t, got, 33) + assert.Equal(t, TypeState, got[0]) + sum := sha256.Sum256(data) + assert.Equal(t, sum[:], got[1:]) +} + +func TestDigest(t *testing.T) { + data := []byte("some-attested-data") + + inner := sha256.Sum256(data) + tagged := append([]byte{0x01}, inner[:]...) + want := sha256.Sum256(tagged) + + assert.Equal(t, want, Digest(TypeState, data)) + assert.Equal(t, TypeState, byte(0x01)) + assert.Equal(t, TypePacket, byte(0x02)) +} + +func TestNormalizeV(t *testing.T) { + sig := make([]byte, 65) + + tests := []struct { + name string + v byte + want byte + wantErr bool + }{ + {"zero-to-27", 0, 27, false}, + {"one-to-28", 1, 28, false}, + {"27-passthrough", 27, 27, false}, + {"28-passthrough", 28, 28, false}, + {"invalid-v", 5, 0, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := make([]byte, 65) + copy(s, sig) + s[64] = tc.v + got, err := NormalizeV(s) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got[64]) + // Input must not be mutated. + assert.Equal(t, tc.v, s[64]) + }) + } + + t.Run("wrong-length", func(t *testing.T) { + _, err := NormalizeV(make([]byte, 64)) + require.Error(t, err) + }) +} + +func TestSignRecoverRoundTrip(t *testing.T) { + key, err := crypto.HexToECDSA(testPrivKey) + require.NoError(t, err) + require.Equal(t, testAddress, crypto.PubkeyToAddress(key.PublicKey).Hex()) + + data := EncodeStateAttestation(100, 200) + digest := Digest(TypeState, data) + + sig, err := crypto.Sign(digest[:], key) + require.NoError(t, err) + + // Deterministic signing: identical bytes on repeat. + sig2, err := crypto.Sign(digest[:], key) + require.NoError(t, err) + assert.Equal(t, sig, sig2) + + norm, err := NormalizeV(sig) + require.NoError(t, err) + assert.Contains(t, []byte{27, 28}, norm[64]) + assert.Equal(t, expectedNorm, hex.EncodeToString(norm)) + + // Recover from normalized (27/28) signature. + signer, err := RecoverSigner(digest, norm) + require.NoError(t, err) + assert.Equal(t, testAddress, signer.Hex()) + + // Recover also works with the raw {0,1} v byte. + signer2, err := RecoverSigner(digest, sig) + require.NoError(t, err) + assert.Equal(t, testAddress, signer2.Hex()) +} + +func TestRecoverSignerErrors(t *testing.T) { + digest := Digest(TypePacket, []byte("x")) + + t.Run("wrong-length", func(t *testing.T) { + _, err := RecoverSigner(digest, make([]byte, 10)) + require.Error(t, err) + }) + + t.Run("invalid-v", func(t *testing.T) { + sig := make([]byte, 65) + sig[64] = 42 + _, err := RecoverSigner(digest, sig) + require.Error(t, err) + }) +} diff --git a/link/internal/bootstrap/bootstrap.go b/link/internal/bootstrap/bootstrap.go index a4c0a32a3..ba469d4a8 100644 --- a/link/internal/bootstrap/bootstrap.go +++ b/link/internal/bootstrap/bootstrap.go @@ -3,80 +3,217 @@ package bootstrap import ( "context" "log/slog" + "time" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains/manager" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/relay/autorelay" + "github.com/cosmos/ibc/link/internal/relay/engine" + "github.com/cosmos/ibc/link/internal/relay/gasmonitor" "github.com/cosmos/ibc/link/internal/server" "github.com/cosmos/ibc/link/internal/service/attestor" "github.com/cosmos/ibc/link/internal/service/relayer" "github.com/cosmos/ibc/link/internal/service/signer" + "github.com/cosmos/ibc/link/internal/service/status" "github.com/cosmos/ibc/link/internal/store" ) +// tracerShutdownTimeout bounds the OTLP tracer flush during graceful shutdown. +const tracerShutdownTimeout = 5 * time.Second + // Services is an outcome of IBC Link wiring (dep inject) type Services struct { Context context.Context Logger *slog.Logger Server *server.Server - Store store.Store - Signers *signer.Set + Store store.Store + ChainClients *manager.ClientManager + + // signers owns the configured signer backends; remote (KMS) signers hold a + // gRPC connection released on Close. Not read outside this package: only + // Services.Close needs it. + signers *signer.Set + + // attestor is the in-process attestor service (dual mode or attestor-only). It + // owns dialed EVM adapters released on Close; nil for a pure relayer using only + // remote attestors. Not read outside this package: only Services.Close needs it. + attestor *attestor.Service + + // ConnectedChains lists chain ids whose eth_chainId probe already succeeded + // during build (the relayed chains, validated against their configured + // numeric id). The startup liveness probe skips re-dialing these. + ConnectedChains []string - RelayerService *relayer.Service - AttestorService *attestor.Service + // Engine, AutoRelay, and GasMonitor are set only when the relayer has + // configured clients. Their loops start and stop together via the cmd layer's + // relay-loop lifecycle. + Engine *engine.Engine + AutoRelay *autorelay.Monitor + GasMonitor *gasmonitor.Monitor + + // tracerShutdown flushes and stops the OTLP tracer provider when tracing was + // installed (OTEL_EXPORTER_OTLP_ENDPOINT set); nil otherwise. + tracerShutdown func(context.Context) error } -// BuildRelayer converts config into a runnable relayer process with all of the deps provisioned -func BuildRelayer(cfg config.Config) (*Services, error) { +// Close releases owned resources (store, chain clients). It is safe to call on a +// partially-built Services and tolerates nil fields; callers wire it into the +// graceful shutdown after the server and engine have stopped. +func (s *Services) Close() error { + var errs []error + + if s.tracerShutdown != nil { + ctx, cancel := context.WithTimeout(context.Background(), tracerShutdownTimeout) + if err := s.tracerShutdown(ctx); err != nil { + errs = append(errs, errors.Wrap(err, "shutting down tracer provider")) + } + cancel() + } + + if s.Store != nil { + if err := s.Store.Close(); err != nil { + errs = append(errs, errors.Wrap(err, "closing store")) + } + } + + if s.ChainClients != nil { + if err := s.ChainClients.Close(); err != nil { + errs = append(errs, errors.Wrap(err, "closing chain clients")) + } + } + + if s.attestor != nil { + if err := s.attestor.Close(); err != nil { + errs = append(errs, errors.Wrap(err, "closing attestor adapters")) + } + } + + if s.signers != nil { + if err := s.signers.Close(); err != nil { + errs = append(errs, errors.Wrap(err, "closing signers")) + } + } + + if len(errs) == 0 { + return nil + } + + return errors.Errorf("closing services: %v", errs) +} + +// BuildRelayer converts config into a runnable relayer process with all of the +// deps provisioned. probeCtx bounds the build-time chain RPC probes (e.g. +// eth_chainId); it must NOT be used for the long-lived process context, which is +// created here (context.Background) so the relay loops outlive the probe budget. +func BuildRelayer(probeCtx context.Context, cfg config.Config) (*Services, error) { ctx := context.Background() logger := slog.With("module", "bootstrap") - // Storage db, err := store.NewStore(ctx, cfg) if err != nil { return nil, err } - // Signers - signers, err := signerSet(ctx, cfg) + clientManager, err := manager.NewFromConfig(cfg) if err != nil { + closeQuietly(logger, "store", db.Close) + return nil, err + } + + // From here on, failures must unwind both the store and chain clients. + services := &Services{ + Context: ctx, + Logger: logger, + Store: db, + ChainClients: clientManager, + } + fail := func(err error) (*Services, error) { + closeQuietly(logger, "services", services.Close) return nil, err } - // Services - relayerService := relayer.New() + signers, err := signerSet(ctx, cfg) + if err != nil { + return fail(err) + } + // From here the signer set (remote gRPC conns) unwinds via services.Close. + services.signers = signers - // Handlers + m := metrics.New() + + tracerShutdown, err := setupTracing(ctx, "ibc-relayer") + if err != nil { + return fail(err) + } + services.tracerShutdown = tracerShutdown + + opts, err := serverOptions(m) + if err != nil { + return fail(err) + } + + relayerService := relayer.New(cfg, db, clientManager) relayerHandler := server.NewRelayerHandler(relayerService) - // Server - srv := server.New(cfg.Server.ListenAddress, true) + srv := server.New(cfg.Server.ListenAddress, true, opts...) srv.Register(relayerHandler) - services := &Services{ - Context: ctx, - Logger: logger, - Server: srv, - Store: db, - Signers: signers, - RelayerService: relayerService, - AttestorService: nil, + // JSON wire endpoints (/health, /relay, /status) plus the additive operator + // view (/status/operator) on the same mux. The operator view reuses the + // `ibc status` assembly with fresh probers: chains.Client exposes no + // router-code / client-registration checks, so a live-client ChainProber + // adapter is not available, and status is an infrequent operator call, so a + // per-request dial is acceptable. + operatorStatus := func(ctx context.Context, opts status.Options) (status.Report, error) { + return status.Assemble(ctx, cfg, db, status.EVMProber{}, status.RemoteAttestorProber{}, opts) } + server.NewHTTPHandler(relayerService, db, cfg, operatorStatus).Register(srv) + + services.Server = srv // Dual mode: if .attestor config is provided, then we can run both relayer and attestor in the same process. // This might be useful for PoC/testing environments or when an operator wants to run the relayer - // and one of attestors in the same process + // and one of attestors in the same process. The engine then routes local attestor entries in-process. + var localAttestor *attestor.Service if len(cfg.Attestor.Attestations) > 0 { logger.Info("Attestor config provided, running in dual mode: relayer with attestor") - attestorService, attestorHandler, err := buildAttestor(cfg, signers) + attestorService, attestorHandler, err := buildAttestor(cfg, signers, m) if err != nil { - return nil, err + return fail(err) } - services.AttestorService = attestorService + // The attestor service owns dialed EVM adapters; unwind them via services.Close. + services.attestor = attestorService + localAttestor = attestorService srv.Register(attestorHandler) } + // Metrics endpoint + gRPC health, after every RPC handler is registered. + registerObservability(srv, m) + + // Relay engine + auto-relay monitor are built only when the relayer has + // clients to relay. Attestor-only configs have neither; a relayer that uses + // only remote attestors still builds them (localAttestor stays nil). + if len(cfg.Relayer.Clients) > 0 { + runtime, err := buildRelayRuntime(probeCtx, cfg, db, clientManager, signers, localAttestor, m, logger) + if err != nil { + return fail(err) + } + + services.Engine = runtime.engine + services.AutoRelay = runtime.autoRelay + services.GasMonitor = runtime.gasMonitor + + // buildChainDelivery already probed (and identity-checked) every relayed + // chain via eth_chainId, so the startup liveness probe need not re-dial them. + services.ConnectedChains = relayChainIDs(cfg.Relayer) + } + return services, nil } @@ -85,35 +222,66 @@ func BuildAttestor(cfg config.Config) (*Services, error) { ctx := context.Background() logger := slog.With("module", "bootstrap") - // Signers + // Accumulate owned resources into services so any wiring failure unwinds the + // signer gRPC conns and dialed attestor adapters instead of leaking them. + services := &Services{ + Context: ctx, + Logger: logger, + Store: nil, // attestor is stateless + } + fail := func(err error) (*Services, error) { + closeQuietly(logger, "services", services.Close) + return nil, err + } + signers, err := signerSet(ctx, cfg) if err != nil { - return nil, err + return fail(err) } + services.signers = signers + + m := metrics.New() - attestorService, attestorHandler, err := buildAttestor(cfg, signers) + // Install tracing BEFORE building serverOptions: the otelconnect interceptor + // captures otel.GetTracerProvider() at construction, so setting the provider + // afterwards would leave every server span on the no-op provider. Recording + // tracerShutdown on services first means the fail path below unwinds the + // provider (via services.Close), so an early wiring failure cannot leak it. + tracerShutdown, err := setupTracing(ctx, "ibc-attestor") if err != nil { - return nil, err + return fail(err) + } + services.tracerShutdown = tracerShutdown + + opts, err := serverOptions(m) + if err != nil { + return fail(err) } - // Server - srv := server.New(cfg.Server.ListenAddress, true) + attestorService, attestorHandler, err := buildAttestor(cfg, signers, m) + if err != nil { + return fail(err) + } + services.attestor = attestorService + + srv := server.New(cfg.Server.ListenAddress, true, opts...) srv.Register(attestorHandler) + services.Server = srv + + // Metrics endpoint + gRPC health, after every RPC handler is registered. + registerObservability(srv, m) + + registerAttestorHealth(srv) - return &Services{ - Context: ctx, - Logger: logger, - Server: srv, - Store: nil, // attestor is stateless - Signers: signers, - RelayerService: nil, - AttestorService: attestorService, - }, nil + return services, nil } -func buildAttestor(cfg config.Config, signers *signer.Set) (*attestor.Service, *server.AttestorHandler, error) { - // Services - attestorService, err := attestor.NewFromConfig(cfg, signers) +func buildAttestor( + cfg config.Config, + signers *signer.Set, + m *metrics.Metrics, +) (*attestor.Service, *server.AttestorHandler, error) { + attestorService, err := attestor.NewFromConfig(cfg, signers, m) if err != nil { return nil, nil, err } @@ -128,13 +296,23 @@ func signerSet(ctx context.Context, cfg config.Config) (*signer.Set, error) { set := signer.NewSet() for _, signerConfig := range cfg.Signers { - signer, alias, err := signer.NewSignerFromConfig(ctx, signerConfig) + s, alias, err := signer.NewSignerFromConfig(ctx, signerConfig) if err != nil { + // Unwind signers dialed so far so a partial failure leaks no gRPC conns. + if cerr := set.Close(); cerr != nil { + slog.Error("failed to close signers during unwind", "err", cerr) + } return nil, err } - set.Set(alias, signer) + set.Set(alias, s) } return set, nil } + +func closeQuietly(logger *slog.Logger, what string, closeFn func() error) { + if err := closeFn(); err != nil { + logger.Error("failed to close resource during unwind", "resource", what, "err", err) + } +} diff --git a/link/internal/bootstrap/close_test.go b/link/internal/bootstrap/close_test.go new file mode 100644 index 000000000..0ba499947 --- /dev/null +++ b/link/internal/bootstrap/close_test.go @@ -0,0 +1,67 @@ +package bootstrap + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/chains/manager" + "github.com/cosmos/ibc/link/internal/service/attestor" + "github.com/cosmos/ibc/link/internal/service/signer" + "github.com/cosmos/ibc/link/internal/store" +) + +// fakeClosableSigner is a signer.Signer that records whether Close was called. +// The embedded Signer is nil (only Close is exercised here). +type fakeClosableSigner struct { + signer.Signer + closed bool +} + +func (f *fakeClosableSigner) Close() error { + f.closed = true + return nil +} + +// TestServicesCloseNilSafe verifies Close tolerates a partially-built Services +// (every owned field nil) as documented. +func TestServicesCloseNilSafe(t *testing.T) { + require.NoError(t, (&Services{}).Close()) +} + +// TestServicesCloseReleasesOwnedResources verifies Close closes the store and +// invokes the signer set's Close (which in turn closes any closable signer). The +// attestor field is exercised here only via attestor.New's no-adapter case (Close +// is a no-op); the adapter-close-join behavior of attestor.Service.Close is +// covered observably in service_close_test.go, since injecting an owned adapter +// requires attestor.NewFromConfig (the adapters field is unexported outside that +// package). +func TestServicesCloseReleasesOwnedResources(t *testing.T) { + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + + attestorSvc, err := attestor.New(nil, nil) + require.NoError(t, err) + + fake := &fakeClosableSigner{} + signers := signer.NewSet() + signers.Set("relayer", fake) + + services := &Services{ + Store: db, + ChainClients: manager.New(map[string]chains.Client{}), + attestor: attestorSvc, + signers: signers, + } + + require.NoError(t, services.Close()) + + // The store handle is closed: a query on it now errors. + assert.Error(t, db.Ping(context.Background())) + + // The signer set's Close was actually invoked (not just a no-op path). + assert.True(t, fake.closed, "signer Close not invoked") +} diff --git a/link/internal/bootstrap/engine.go b/link/internal/bootstrap/engine.go new file mode 100644 index 000000000..aedb3b8c5 --- /dev/null +++ b/link/internal/bootstrap/engine.go @@ -0,0 +1,313 @@ +package bootstrap + +import ( + "context" + "log/slog" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/chains/manager" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/relay/autorelay" + "github.com/cosmos/ibc/link/internal/relay/engine" + "github.com/cosmos/ibc/link/internal/relay/gasmonitor" + "github.com/cosmos/ibc/link/internal/relay/proofgen" + "github.com/cosmos/ibc/link/internal/service/attestor" + "github.com/cosmos/ibc/link/internal/service/signer" + "github.com/cosmos/ibc/link/internal/store" +) + +type relayRuntime struct { + engine *engine.Engine + autoRelay *autorelay.Monitor + gasMonitor *gasmonitor.Monitor +} + +func buildRelayRuntime( + ctx context.Context, + cfg config.Config, + db store.Store, + clientManager *manager.ClientManager, + signers *signer.Set, + localAttestor *attestor.Service, + m *metrics.Metrics, + logger *slog.Logger, +) (*relayRuntime, error) { + relaySigner, ok := signers.Get(cfg.Relayer.Signer) + if !ok { + return nil, errors.Errorf("relayer signer %q not found", cfg.Relayer.Signer) + } + + delivery, err := buildChainDelivery(ctx, cfg, clientManager, relaySigner, logger) + if err != nil { + return nil, err + } + + // Resolve local attestor identities from the already-loaded signer set (not a + // fresh key-file read) so an atomic key rotation cannot split the address the + // aggregator enforces from the key the in-process attestor actually signs with. + localSignerAddrs, err := localSignerAddresses(cfg, signers) + if err != nil { + return nil, err + } + + proofGens, err := buildProofGens(cfg, localAttestor, localSignerAddrs) + if err != nil { + return nil, err + } + + eng, err := engine.New(engine.Params{ + Store: db, + ChainClients: clientManager, + Submitters: delivery.submitters, + TxBuilders: delivery.txBuilders, + ProofGens: proofGens, + Relayer: cfg.Relayer, + Metrics: m, + Logger: logger, + }) + if err != nil { + return nil, errors.Wrap(err, "building relay engine") + } + + monitor, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clientManager, + Relayer: cfg.Relayer, + Logger: logger, + }) + if err != nil { + return nil, errors.Wrap(err, "building auto-relay monitor") + } + + return &relayRuntime{ + engine: eng, + autoRelay: monitor, + gasMonitor: gasmonitor.New(delivery.gasTargets, m, logger), + }, nil +} + +type chainDelivery struct { + submitters map[string]engine.Submitter + txBuilders map[string]engine.TxBuilder + gasTargets []gasmonitor.Target +} + +func buildChainDelivery( + ctx context.Context, + cfg config.Config, + clientManager *manager.ClientManager, + relaySigner signer.Signer, + logger *slog.Logger, +) (*chainDelivery, error) { + delivery := &chainDelivery{ + submitters: make(map[string]engine.Submitter), + txBuilders: make(map[string]engine.TxBuilder), + } + + for _, chainID := range relayChainIDs(cfg.Relayer) { + chainCfg, ok := cfg.Chain(chainID) + if !ok { + return nil, errors.Errorf("relayer references chain %q not declared in top-level chains", chainID) + } + if chainCfg.Type() != config.ChainTypeEVM { + return nil, errors.Errorf("unsupported chain type for relayed chain %q", chainID) + } + + client, err := clientManager.GetClient(chainID) + if err != nil { + return nil, err + } + + evmClient, ok := client.(*evm.Client) + if !ok { + return nil, errors.Errorf("chain %q client is not an evm client", chainID) + } + + numericID, err := evmClient.ChainID(ctx) + if err != nil { + // A chain-id probe that cannot reach the RPC (or times out) is an + // unreachable-endpoint failure, mapped to the RPC-unreachable exit + // code by the caller — distinct from a config/identity mismatch. + return nil, &ProbeError{ChainID: chainID, err: errors.Wrapf(err, "chain %q chain-id probe", chainID)} + } + if err = evm.CheckChainID(chainID, numericID); err != nil { + return nil, err + } + + txSigner, err := signer.NewEVMTxSigner(relaySigner, numericID) + if err != nil { + return nil, errors.Wrapf(err, "building evm tx signer for chain %q", chainID) + } + + override, _ := relayerChainOverride(cfg.Relayer, chainID) + + delivery.submitters[chainID] = evm.NewSubmitter(evm.SubmitterConfig{ + Eth: evmClient.Eth(), + Signer: txSigner, + From: txSigner.From(), + ChainID: numericID, + TxSubmissionDelay: txSubmissionDelay(override), + GasFeeCapMultiplier: gasFeeCapMultiplier(override), + GasTipCapMultiplier: gasTipCapMultiplier(override), + Logger: logger, + }) + + txBuilder, err := evm.NewTxBuilder(chainCfg.EVM.ICS26Router) + if err != nil { + return nil, errors.Wrapf(err, "building tx builder for chain %q", chainID) + } + delivery.txBuilders[chainID] = txBuilder + + target := gasmonitor.Target{ + ChainID: chainID, + Address: txSigner.From(), + Source: evmClient, + } + if thresholds := cfg.Relayer.GasAlertThresholdsFor(chainID); thresholds != nil { + target.Warning, target.Critical, err = thresholds.Parse() + if err != nil { + return nil, errors.Wrapf(err, "gas alert thresholds for chain %q", chainID) + } + } + delivery.gasTargets = append(delivery.gasTargets, target) + } + + return delivery, nil +} + +// buildProofGens builds one ProofGenerator per configured client, keyed by +// "chainID/clientID" to match the engine's route resolution. +func buildProofGens( + cfg config.Config, localAttestor *attestor.Service, localSignerAddrs map[string]common.Address, +) (map[string]engine.ProofGenerator, error) { + proofGens := make(map[string]engine.ProofGenerator) + + for _, client := range cfg.Relayer.Clients { + if client.AttestorSet == nil { + return nil, errors.Errorf("client %q has no attestor set", client.Alias) + } + + gen, err := buildProofGen(cfg, *client.AttestorSet, localAttestor, localSignerAddrs) + if err != nil { + return nil, errors.Wrapf(err, "building proof generator for client %q", client.Alias) + } + + proofGens[engine.ClientKey(client.ChainID, client.ClientID)] = gen + } + + return proofGens, nil +} + +// localSignerAddresses derives the EVM address of every configured local +// secp256k1 signer from its already-loaded in-process key, keyed by signer alias. +// Non-local and non-ECDSA signers (which have no locally derivable EVM address) +// are omitted; a local attestor bound to one must set an explicit evmAddress. +func localSignerAddresses(cfg config.Config, signers *signer.Set) (map[string]common.Address, error) { + addrs := make(map[string]common.Address) + + for _, sc := range cfg.Signers { + if sc.Type != config.SignerLocal { + continue + } + + s, ok := signers.Get(sc.Alias) + if !ok || s.Type() != signer.ECDSA { + continue + } + + addr, err := signer.EVMAddress(s.PublicKey()) + if err != nil { + return nil, errors.Wrapf(err, "deriving evm address for local signer %q", sc.Alias) + } + addrs[sc.Alias] = addr + } + + return addrs, nil +} + +func buildProofGen( + cfg config.Config, + set config.AttestorSetConfig, + localAttestor *attestor.Service, + localSignerAddrs map[string]common.Address, +) (engine.ProofGenerator, error) { + members, err := proofgen.ClientsFromConfig(cfg, set, localAttestor, localSignerAddrs) + if err != nil { + return nil, err + } + + agg, err := proofgen.NewAggregator(members, set.Threshold, 0) + if err != nil { + return nil, err + } + + // ADR-011: the relayer treats counterparty-finalized as latest - offset. + return proofgen.NewGenerator(agg, set.CounterpartyChainFinalityOffset) +} + +// relayChainIDs returns the deduplicated set of chain ids referenced by the +// relayer clients, in first-seen order. Bidirectional client validation +// guarantees this covers both sides of every route. +func relayChainIDs(cfg config.RelayerConfig) []string { + seen := make(map[string]struct{}, len(cfg.Clients)) + ids := make([]string, 0, len(cfg.Clients)) + + for _, client := range cfg.Clients { + if _, ok := seen[client.ChainID]; ok { + continue + } + seen[client.ChainID] = struct{}{} + ids = append(ids, client.ChainID) + } + + return ids +} + +func relayerChainOverride(cfg config.RelayerConfig, chainID string) (config.RelayerChainOverride, bool) { + for _, override := range cfg.ChainOverrides { + if override.ChainID == chainID { + return override, true + } + } + + return config.RelayerChainOverride{}, false +} + +func txSubmissionDelay(override config.RelayerChainOverride) time.Duration { + if override.TxSubmissionDelay != nil { + return *override.TxSubmissionDelay + } + + return 0 +} + +func gasFeeCapMultiplier(override config.RelayerChainOverride) *float64 { + if override.EVM == nil { + return nil + } + + return override.EVM.GasFeeCapMultiplier +} + +func gasTipCapMultiplier(override config.RelayerChainOverride) *float64 { + if override.EVM == nil { + return nil + } + + return override.EVM.GasTipCapMultiplier +} + +// ProbeError marks a startup chain liveness/identity probe that could not reach +// its RPC endpoint, so the caller can map it to the RPC-unreachable exit code. +type ProbeError struct { + ChainID string + err error +} + +func (e *ProbeError) Error() string { return e.err.Error() } +func (e *ProbeError) Unwrap() error { return e.err } diff --git a/link/internal/bootstrap/engine_test.go b/link/internal/bootstrap/engine_test.go new file mode 100644 index 000000000..de2477588 --- /dev/null +++ b/link/internal/bootstrap/engine_test.go @@ -0,0 +1,170 @@ +package bootstrap + +import ( + "context" + "log/slog" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/chains/manager" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/signer" + "github.com/cosmos/ibc/link/internal/store" +) + +type ethWithChainID struct { + *evm.MockETHClient + id *big.Int +} + +func (e ethWithChainID) ChainID(context.Context) (*big.Int, error) { return e.id, nil } + +const router = "0x1111111111111111111111111111111111111111" + +func evmClientFor(t *testing.T, chainID string, numeric int64) *evm.Client { + t.Helper() + + eth := ethWithChainID{MockETHClient: evm.NewMockETHClient(t), id: big.NewInt(numeric)} + client, err := evm.NewWithClient(chainID, eth, router) + require.NoError(t, err) + + return client +} + +func wiringConfig() config.Config { + remoteSet := func(name, evmAddr string) *config.AttestorSetConfig { + return &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: name, Type: config.AttestorTypeRemote, GRPC: "localhost:9999", EVMAddress: evmAddr}, + }, + } + } + + return config.Config{ + Chains: []config.ChainConfig{ + {ChainID: "1", EVM: &config.EVMChainConfig{RPC: "http://localhost:8545", ICS26Router: router}}, + {ChainID: "2", EVM: &config.EVMChainConfig{RPC: "http://localhost:8546", ICS26Router: router}}, + }, + Relayer: config.RelayerConfig{ + Signer: "relayer", + Clients: []config.ClientConfig{ + { + Alias: "a", ClientID: "clientA", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "clientB", + Type: config.ClientTypeAttestation, + AttestorSet: remoteSet("att-a", "0x00000000000000000000000000000000000000Aa"), + }, + { + Alias: "b", ClientID: "clientB", ChainID: "2", + CounterpartyChainID: "1", CounterpartyClientID: "clientA", + Type: config.ClientTypeAttestation, + AttestorSet: remoteSet("att-b", "0x00000000000000000000000000000000000000Bb"), + }, + }, + Routes: []config.RouteConfig{{SourceClient: "a"}}, + }, + } +} + +func TestBuildRelayRuntimeWiring(t *testing.T) { + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + clientManager := manager.New(map[string]chains.Client{ + "1": evmClientFor(t, "1", 1), + "2": evmClientFor(t, "2", 2), + }) + + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + signers := signer.NewSet() + signers.Set("relayer", sgn) + + cfg := wiringConfig() + + runtime, err := buildRelayRuntime( + context.Background(), cfg, db, clientManager, signers, nil, nil, slog.Default(), + ) + require.NoError(t, err) + assert.NotNil(t, runtime.engine) + assert.NotNil(t, runtime.autoRelay) + assert.NotNil(t, runtime.gasMonitor) +} + +func TestBuildRelayRuntimeChainIDMismatch(t *testing.T) { + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + clientManager := manager.New(map[string]chains.Client{ + "1": evmClientFor(t, "1", 1), + "2": evmClientFor(t, "2", 999), + }) + + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + signers := signer.NewSet() + signers.Set("relayer", sgn) + + _, err = buildRelayRuntime(context.Background(), wiringConfig(), db, clientManager, signers, nil, nil, slog.Default()) + require.Error(t, err) + + var mismatch *evm.ChainIDMismatchError + require.ErrorAs(t, err, &mismatch) + assert.Equal(t, "2", mismatch.Configured) + assert.Equal(t, "999", mismatch.Reported) +} + +func TestBuildRelayRuntimeNonNumericChainIDExempt(t *testing.T) { + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + cfg := wiringConfig() + cfg.Chains[0].ChainID = "chain-label" + cfg.Relayer.Clients[0].ChainID = "chain-label" + cfg.Relayer.Clients[1].CounterpartyChainID = "chain-label" + + clientManager := manager.New(map[string]chains.Client{ + "chain-label": evmClientFor(t, "chain-label", 1), + "2": evmClientFor(t, "2", 2), + }) + + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + signers := signer.NewSet() + signers.Set("relayer", sgn) + + _, err = buildRelayRuntime(context.Background(), cfg, db, clientManager, signers, nil, nil, slog.Default()) + require.NoError(t, err) +} + +func TestBuildRelayRuntimeLocalAttestorRequiresService(t *testing.T) { + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + clientManager := manager.New(map[string]chains.Client{ + "1": evmClientFor(t, "1", 1), + "2": evmClientFor(t, "2", 2), + }) + + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + signers := signer.NewSet() + signers.Set("relayer", sgn) + + cfg := wiringConfig() + cfg.Relayer.Clients[0].AttestorSet.Attestors[0] = config.AttestorEntry{Name: "att-a", Type: config.AttestorTypeLocal} + + _, err = buildRelayRuntime(context.Background(), cfg, db, clientManager, signers, nil, nil, slog.Default()) + require.Error(t, err) + assert.Contains(t, err.Error(), "local attestor") +} diff --git a/link/internal/bootstrap/observability.go b/link/internal/bootstrap/observability.go new file mode 100644 index 000000000..11e8d5758 --- /dev/null +++ b/link/internal/bootstrap/observability.go @@ -0,0 +1,57 @@ +package bootstrap + +import ( + "net/http" + + "connectrpc.com/connect" + "connectrpc.com/grpchealth" + "connectrpc.com/otelconnect" + + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/server" +) + +const ( + metricsPath = "GET /metrics" + attestorHealthPath = "GET /health" +) + +// serverOptions builds the Connect handler options shared by every RPC handler: +// a metrics interceptor plus the otelconnect tracing interceptor (which is a +// no-op until a real TracerProvider is installed via setupTracing). +func serverOptions(m *metrics.Metrics) ([]connect.HandlerOption, error) { + tracing, err := otelconnect.NewInterceptor(otelconnect.WithTrustRemote()) + if err != nil { + return nil, err + } + + return []connect.HandlerOption{connect.WithInterceptors( + server.MetricsInterceptor(m), + tracing, + )}, nil +} + +// registerObservability mounts the /metrics endpoint and the Connect gRPC +// health service (statically SERVING for the process's registered RPC +// services) on the server's mux. Call after all RPC handlers are registered so +// the health service reports every service the process serves. +func registerObservability(srv *server.Server, m *metrics.Metrics) { + srv.Handle(metricsPath, m.Handler()) + + checker := grpchealth.NewStaticChecker(srv.ServiceNames()...) + path, handler := grpchealth.NewHandler(checker) + srv.Handle(path, handler) +} + +// registerAttestorHealth mounts GET /health for the standalone attestor's HTTP +// server. The attestor holds no database, so a served request is itself the +// readiness signal: the handler always answers 200 with an empty JSON object, +// matching the relayer's /health success shape (the e2e harness decodes no body +// fields). It is registered only from the attestor path; the relayer serves +// /health via server.NewHTTPHandler, so mounting it here too would double-register. +func registerAttestorHealth(srv *server.Server) { + srv.Handle(attestorHealthPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{}")) + })) +} diff --git a/link/internal/bootstrap/observability_test.go b/link/internal/bootstrap/observability_test.go new file mode 100644 index 000000000..8d241b370 --- /dev/null +++ b/link/internal/bootstrap/observability_test.go @@ -0,0 +1,88 @@ +package bootstrap + +import ( + "context" + "io" + "net/http" + "testing" + + "connectrpc.com/connect" + "connectrpc.com/grpchealth" + "github.com/stretchr/testify/require" + + proto "github.com/cosmos/ibc/link/api/v2/relayer" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/server" + "github.com/cosmos/ibc/link/internal/service/relayer" +) + +// fakeRelayerService is a no-op server.RelayerService so the observability test +// can register a real RPC handler without a store or chain clients. +type fakeRelayerService struct{} + +func (fakeRelayerService) Relay(context.Context, string, string) ([]relayer.PacketStatus, error) { + return nil, nil +} + +func (fakeRelayerService) Status(context.Context, string, string) ([]relayer.PacketStatus, error) { + return nil, nil +} + +// TestObservabilityWiring exercises the real serverOptions + registerObservability +// path (the same wiring BuildRelayer/BuildAttestor use), then drives one RPC and +// asserts: +// - the RPC round-trips through the metrics interceptor, +// - /metrics serves 200 with the RPC counter family present, +// - the gRPC health service reports SERVING for the registered service. +func TestObservabilityWiring(t *testing.T) { + m := metrics.New() + + opts, err := serverOptions(m) + require.NoError(t, err) + + srv := server.New("127.0.0.1:0", false, opts...) + srv.Register(server.NewRelayerHandler(fakeRelayerService{})) + registerObservability(srv, m) + + require.NoError(t, srv.Start()) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + + base := "http://" + srv.Addr() + ctx := context.Background() + + // Drive one RPC so the interceptor records a sample. + client := proto.NewRelayerApiServiceClient(http.DefaultClient, base) + _, err = client.Relay(ctx, connect.NewRequest(&proto.RelayRequest{ChainId: "1", TxHash: "0xabc"})) + require.NoError(t, err) + + // /metrics responds 200 and exposes the RPC counter for the procedure. + resp, err := http.Get(base + "/metrics") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "ibclink_rpc_requests_total") + require.Contains(t, string(body), "Relay") + + // Health service reports SERVING for the registered relayer service. + healthClient := grpchealth.NewClient(http.DefaultClient, base) + got, err := healthClient.Check(ctx, &grpchealth.CheckRequest{Service: proto.RelayerApiServiceName}) + require.NoError(t, err) + require.Equal(t, grpchealth.StatusServing, got.Status) +} + +func TestAttestorHTTPHealth(t *testing.T) { + srv := server.New("127.0.0.1:0", false) + registerAttestorHealth(srv) + + require.NoError(t, srv.Start()) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + + resp, err := http.Get("http://" + srv.Addr() + "/health") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/link/internal/bootstrap/tracing.go b/link/internal/bootstrap/tracing.go new file mode 100644 index 000000000..232f8f93b --- /dev/null +++ b/link/internal/bootstrap/tracing.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "context" + "os" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +// otlpEndpointEnv gates real tracing: when set, an OTLP-gRPC exporter is +// installed as the global TracerProvider. Unset, the otelconnect interceptor +// resolves to the no-op provider and tracing costs nothing. +const otlpEndpointEnv = "OTEL_EXPORTER_OTLP_ENDPOINT" + +// setupTracing installs a global OTLP-gRPC TracerProvider when +// OTEL_EXPORTER_OTLP_ENDPOINT is set and returns its shutdown func for +// Services.Close. When the env var is unset it is a no-op: the returned +// shutdown is nil and the global provider stays the default no-op. +// +// The exporter reads its endpoint (and other OTEL_EXPORTER_OTLP_* knobs) from +// the environment, so no config plumbing is needed. +func setupTracing(ctx context.Context, service string) (func(context.Context) error, error) { + if os.Getenv(otlpEndpointEnv) == "" { + return nil, nil + } + + exporter, err := otlptracegrpc.New(ctx) + if err != nil { + return nil, err + } + + res, err := resource.Merge(resource.Default(), resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(service), + )) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return tp.Shutdown, nil +} diff --git a/link/internal/chains/chains.go b/link/internal/chains/chains.go new file mode 100644 index 000000000..2013d308e --- /dev/null +++ b/link/internal/chains/chains.go @@ -0,0 +1,91 @@ +// Package chains defines chain-agnostic clients for reading chain state. +package chains + +import ( + "context" + "time" + + "github.com/pkg/errors" +) + +// ErrTxReverted indicates the queried transaction reverted on-chain. +var ErrTxReverted = errors.New("transaction reverted") + +// Client provides chain state queries. +type Client interface { + // TxPacketEvents parses all packet lifecycle events (SendPacket, + // WriteAcknowledgement, AckPacket, TimeoutPacket) from a transaction receipt. + TxPacketEvents(ctx context.Context, txHash []byte) ([]PacketEvent, error) + + // SendPacketEvents range-scans the router for SendPacket events between the + // given block heights (inclusive). + SendPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]PacketEvent, error) + + // WriteAckEvents range-scans the router for WriteAcknowledgement events + // between the given block heights (inclusive). + WriteAckEvents(ctx context.Context, fromBlock, toBlock uint64) ([]PacketEvent, error) + + // AckPacketEvents range-scans the router for AckPacket events between the + // given block heights (inclusive). Emitted on the source chain when a packet + // is acknowledged; used to classify out-of-band completion. + AckPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]PacketEvent, error) + + // TimeoutPacketEvents range-scans the router for TimeoutPacket events between + // the given block heights (inclusive). Emitted on the source chain when a + // packet times out; used to classify out-of-band completion. + TimeoutPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]PacketEvent, error) + + // LatestHeight returns the latest block number. + LatestHeight(ctx context.Context) (uint64, error) + + // FinalizedHeight returns the finalized block number. A nil offset uses the + // chain's native finalized tag; a set offset returns latest-offset (saturating). + FinalizedHeight(ctx context.Context, finalityOffset *uint64) (uint64, error) + + // HeaderTimestamp returns the block time at the given height. + HeaderTimestamp(ctx context.Context, height uint64) (time.Time, error) + + // GetCommitment reads the commitment stored at hashedPath, pinned to the + // given block height. A zero value means the commitment is absent. + GetCommitment(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error) +} + +// EventKind the kind of packet event. +type EventKind int + +// Event kinds +const ( + KindSendPacket EventKind = iota + KindWriteAck + KindAckPacket + KindTimeoutPacket +) + +// Payload a packet payload. +type Payload struct { + SourcePort string + DestPort string + Version string + Encoding string + Value []byte +} + +// Packet an IBC v2 packet. +type Packet struct { + Sequence uint64 + SourceClient string + DestClient string + TimeoutTimestamp uint64 + Payloads []Payload +} + +// PacketEvent a packet event. +type PacketEvent struct { + Height uint64 + BlockTime time.Time + // TxHash the 0x-prefixed hex hash of the transaction that emitted the event. + TxHash string + Kind EventKind + Packet Packet + Acks [][]byte +} diff --git a/link/internal/chains/chains.mock.go b/link/internal/chains/chains.mock.go new file mode 100644 index 000000000..3a51a4f37 --- /dev/null +++ b/link/internal/chains/chains.mock.go @@ -0,0 +1,668 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package chains + +import ( + "context" + mock "github.com/stretchr/testify/mock" + "time" +) + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// AckPacketEvents provides a mock function for the type MockClient +func (_mock *MockClient) AckPacketEvents(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error) { + ret := _mock.Called(ctx, fromBlock, toBlock) + + if len(ret) == 0 { + panic("no return value specified for AckPacketEvents") + } + + var r0 []PacketEvent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) ([]PacketEvent, error)); ok { + return returnFunc(ctx, fromBlock, toBlock) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) []PacketEvent); ok { + r0 = returnFunc(ctx, fromBlock, toBlock) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]PacketEvent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uint64, uint64) error); ok { + r1 = returnFunc(ctx, fromBlock, toBlock) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_AckPacketEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AckPacketEvents' +type MockClient_AckPacketEvents_Call struct { + *mock.Call +} + +// AckPacketEvents is a helper method to define mock.On call +// - ctx context.Context +// - fromBlock uint64 +// - toBlock uint64 +func (_e *MockClient_Expecter) AckPacketEvents(ctx any, fromBlock any, toBlock any) *MockClient_AckPacketEvents_Call { + return &MockClient_AckPacketEvents_Call{Call: _e.mock.On("AckPacketEvents", ctx, fromBlock, toBlock)} +} + +func (_c *MockClient_AckPacketEvents_Call) Run(run func(ctx context.Context, fromBlock uint64, toBlock uint64)) *MockClient_AckPacketEvents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_AckPacketEvents_Call) Return(packetEvents []PacketEvent, err error) *MockClient_AckPacketEvents_Call { + _c.Call.Return(packetEvents, err) + return _c +} + +func (_c *MockClient_AckPacketEvents_Call) RunAndReturn(run func(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error)) *MockClient_AckPacketEvents_Call { + _c.Call.Return(run) + return _c +} + +// FinalizedHeight provides a mock function for the type MockClient +func (_mock *MockClient) FinalizedHeight(ctx context.Context, finalityOffset *uint64) (uint64, error) { + ret := _mock.Called(ctx, finalityOffset) + + if len(ret) == 0 { + panic("no return value specified for FinalizedHeight") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *uint64) (uint64, error)); ok { + return returnFunc(ctx, finalityOffset) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *uint64) uint64); ok { + r0 = returnFunc(ctx, finalityOffset) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *uint64) error); ok { + r1 = returnFunc(ctx, finalityOffset) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_FinalizedHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinalizedHeight' +type MockClient_FinalizedHeight_Call struct { + *mock.Call +} + +// FinalizedHeight is a helper method to define mock.On call +// - ctx context.Context +// - finalityOffset *uint64 +func (_e *MockClient_Expecter) FinalizedHeight(ctx any, finalityOffset any) *MockClient_FinalizedHeight_Call { + return &MockClient_FinalizedHeight_Call{Call: _e.mock.On("FinalizedHeight", ctx, finalityOffset)} +} + +func (_c *MockClient_FinalizedHeight_Call) Run(run func(ctx context.Context, finalityOffset *uint64)) *MockClient_FinalizedHeight_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *uint64 + if args[1] != nil { + arg1 = args[1].(*uint64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_FinalizedHeight_Call) Return(v uint64, err error) *MockClient_FinalizedHeight_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockClient_FinalizedHeight_Call) RunAndReturn(run func(ctx context.Context, finalityOffset *uint64) (uint64, error)) *MockClient_FinalizedHeight_Call { + _c.Call.Return(run) + return _c +} + +// GetCommitment provides a mock function for the type MockClient +func (_mock *MockClient) GetCommitment(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error) { + ret := _mock.Called(ctx, hashedPath, height) + + if len(ret) == 0 { + panic("no return value specified for GetCommitment") + } + + var r0 [32]byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, [32]byte, uint64) ([32]byte, error)); ok { + return returnFunc(ctx, hashedPath, height) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, [32]byte, uint64) [32]byte); ok { + r0 = returnFunc(ctx, hashedPath, height) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([32]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, [32]byte, uint64) error); ok { + r1 = returnFunc(ctx, hashedPath, height) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetCommitment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommitment' +type MockClient_GetCommitment_Call struct { + *mock.Call +} + +// GetCommitment is a helper method to define mock.On call +// - ctx context.Context +// - hashedPath [32]byte +// - height uint64 +func (_e *MockClient_Expecter) GetCommitment(ctx any, hashedPath any, height any) *MockClient_GetCommitment_Call { + return &MockClient_GetCommitment_Call{Call: _e.mock.On("GetCommitment", ctx, hashedPath, height)} +} + +func (_c *MockClient_GetCommitment_Call) Run(run func(ctx context.Context, hashedPath [32]byte, height uint64)) *MockClient_GetCommitment_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 [32]byte + if args[1] != nil { + arg1 = args[1].([32]byte) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_GetCommitment_Call) Return(bytes [32]byte, err error) *MockClient_GetCommitment_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *MockClient_GetCommitment_Call) RunAndReturn(run func(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error)) *MockClient_GetCommitment_Call { + _c.Call.Return(run) + return _c +} + +// HeaderTimestamp provides a mock function for the type MockClient +func (_mock *MockClient) HeaderTimestamp(ctx context.Context, height uint64) (time.Time, error) { + ret := _mock.Called(ctx, height) + + if len(ret) == 0 { + panic("no return value specified for HeaderTimestamp") + } + + var r0 time.Time + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) (time.Time, error)); ok { + return returnFunc(ctx, height) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) time.Time); ok { + r0 = returnFunc(ctx, height) + } else { + r0 = ret.Get(0).(time.Time) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uint64) error); ok { + r1 = returnFunc(ctx, height) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_HeaderTimestamp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HeaderTimestamp' +type MockClient_HeaderTimestamp_Call struct { + *mock.Call +} + +// HeaderTimestamp is a helper method to define mock.On call +// - ctx context.Context +// - height uint64 +func (_e *MockClient_Expecter) HeaderTimestamp(ctx any, height any) *MockClient_HeaderTimestamp_Call { + return &MockClient_HeaderTimestamp_Call{Call: _e.mock.On("HeaderTimestamp", ctx, height)} +} + +func (_c *MockClient_HeaderTimestamp_Call) Run(run func(ctx context.Context, height uint64)) *MockClient_HeaderTimestamp_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_HeaderTimestamp_Call) Return(time1 time.Time, err error) *MockClient_HeaderTimestamp_Call { + _c.Call.Return(time1, err) + return _c +} + +func (_c *MockClient_HeaderTimestamp_Call) RunAndReturn(run func(ctx context.Context, height uint64) (time.Time, error)) *MockClient_HeaderTimestamp_Call { + _c.Call.Return(run) + return _c +} + +// LatestHeight provides a mock function for the type MockClient +func (_mock *MockClient) LatestHeight(ctx context.Context) (uint64, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for LatestHeight") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_LatestHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestHeight' +type MockClient_LatestHeight_Call struct { + *mock.Call +} + +// LatestHeight is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) LatestHeight(ctx any) *MockClient_LatestHeight_Call { + return &MockClient_LatestHeight_Call{Call: _e.mock.On("LatestHeight", ctx)} +} + +func (_c *MockClient_LatestHeight_Call) Run(run func(ctx context.Context)) *MockClient_LatestHeight_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockClient_LatestHeight_Call) Return(v uint64, err error) *MockClient_LatestHeight_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockClient_LatestHeight_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *MockClient_LatestHeight_Call { + _c.Call.Return(run) + return _c +} + +// SendPacketEvents provides a mock function for the type MockClient +func (_mock *MockClient) SendPacketEvents(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error) { + ret := _mock.Called(ctx, fromBlock, toBlock) + + if len(ret) == 0 { + panic("no return value specified for SendPacketEvents") + } + + var r0 []PacketEvent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) ([]PacketEvent, error)); ok { + return returnFunc(ctx, fromBlock, toBlock) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) []PacketEvent); ok { + r0 = returnFunc(ctx, fromBlock, toBlock) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]PacketEvent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uint64, uint64) error); ok { + r1 = returnFunc(ctx, fromBlock, toBlock) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_SendPacketEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendPacketEvents' +type MockClient_SendPacketEvents_Call struct { + *mock.Call +} + +// SendPacketEvents is a helper method to define mock.On call +// - ctx context.Context +// - fromBlock uint64 +// - toBlock uint64 +func (_e *MockClient_Expecter) SendPacketEvents(ctx any, fromBlock any, toBlock any) *MockClient_SendPacketEvents_Call { + return &MockClient_SendPacketEvents_Call{Call: _e.mock.On("SendPacketEvents", ctx, fromBlock, toBlock)} +} + +func (_c *MockClient_SendPacketEvents_Call) Run(run func(ctx context.Context, fromBlock uint64, toBlock uint64)) *MockClient_SendPacketEvents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_SendPacketEvents_Call) Return(packetEvents []PacketEvent, err error) *MockClient_SendPacketEvents_Call { + _c.Call.Return(packetEvents, err) + return _c +} + +func (_c *MockClient_SendPacketEvents_Call) RunAndReturn(run func(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error)) *MockClient_SendPacketEvents_Call { + _c.Call.Return(run) + return _c +} + +// TimeoutPacketEvents provides a mock function for the type MockClient +func (_mock *MockClient) TimeoutPacketEvents(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error) { + ret := _mock.Called(ctx, fromBlock, toBlock) + + if len(ret) == 0 { + panic("no return value specified for TimeoutPacketEvents") + } + + var r0 []PacketEvent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) ([]PacketEvent, error)); ok { + return returnFunc(ctx, fromBlock, toBlock) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) []PacketEvent); ok { + r0 = returnFunc(ctx, fromBlock, toBlock) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]PacketEvent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uint64, uint64) error); ok { + r1 = returnFunc(ctx, fromBlock, toBlock) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_TimeoutPacketEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TimeoutPacketEvents' +type MockClient_TimeoutPacketEvents_Call struct { + *mock.Call +} + +// TimeoutPacketEvents is a helper method to define mock.On call +// - ctx context.Context +// - fromBlock uint64 +// - toBlock uint64 +func (_e *MockClient_Expecter) TimeoutPacketEvents(ctx any, fromBlock any, toBlock any) *MockClient_TimeoutPacketEvents_Call { + return &MockClient_TimeoutPacketEvents_Call{Call: _e.mock.On("TimeoutPacketEvents", ctx, fromBlock, toBlock)} +} + +func (_c *MockClient_TimeoutPacketEvents_Call) Run(run func(ctx context.Context, fromBlock uint64, toBlock uint64)) *MockClient_TimeoutPacketEvents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_TimeoutPacketEvents_Call) Return(packetEvents []PacketEvent, err error) *MockClient_TimeoutPacketEvents_Call { + _c.Call.Return(packetEvents, err) + return _c +} + +func (_c *MockClient_TimeoutPacketEvents_Call) RunAndReturn(run func(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error)) *MockClient_TimeoutPacketEvents_Call { + _c.Call.Return(run) + return _c +} + +// TxPacketEvents provides a mock function for the type MockClient +func (_mock *MockClient) TxPacketEvents(ctx context.Context, txHash []byte) ([]PacketEvent, error) { + ret := _mock.Called(ctx, txHash) + + if len(ret) == 0 { + panic("no return value specified for TxPacketEvents") + } + + var r0 []PacketEvent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) ([]PacketEvent, error)); ok { + return returnFunc(ctx, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) []PacketEvent); ok { + r0 = returnFunc(ctx, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]PacketEvent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, []byte) error); ok { + r1 = returnFunc(ctx, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_TxPacketEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TxPacketEvents' +type MockClient_TxPacketEvents_Call struct { + *mock.Call +} + +// TxPacketEvents is a helper method to define mock.On call +// - ctx context.Context +// - txHash []byte +func (_e *MockClient_Expecter) TxPacketEvents(ctx any, txHash any) *MockClient_TxPacketEvents_Call { + return &MockClient_TxPacketEvents_Call{Call: _e.mock.On("TxPacketEvents", ctx, txHash)} +} + +func (_c *MockClient_TxPacketEvents_Call) Run(run func(ctx context.Context, txHash []byte)) *MockClient_TxPacketEvents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []byte + if args[1] != nil { + arg1 = args[1].([]byte) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_TxPacketEvents_Call) Return(packetEvents []PacketEvent, err error) *MockClient_TxPacketEvents_Call { + _c.Call.Return(packetEvents, err) + return _c +} + +func (_c *MockClient_TxPacketEvents_Call) RunAndReturn(run func(ctx context.Context, txHash []byte) ([]PacketEvent, error)) *MockClient_TxPacketEvents_Call { + _c.Call.Return(run) + return _c +} + +// WriteAckEvents provides a mock function for the type MockClient +func (_mock *MockClient) WriteAckEvents(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error) { + ret := _mock.Called(ctx, fromBlock, toBlock) + + if len(ret) == 0 { + panic("no return value specified for WriteAckEvents") + } + + var r0 []PacketEvent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) ([]PacketEvent, error)); ok { + return returnFunc(ctx, fromBlock, toBlock) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64, uint64) []PacketEvent); ok { + r0 = returnFunc(ctx, fromBlock, toBlock) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]PacketEvent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uint64, uint64) error); ok { + r1 = returnFunc(ctx, fromBlock, toBlock) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_WriteAckEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteAckEvents' +type MockClient_WriteAckEvents_Call struct { + *mock.Call +} + +// WriteAckEvents is a helper method to define mock.On call +// - ctx context.Context +// - fromBlock uint64 +// - toBlock uint64 +func (_e *MockClient_Expecter) WriteAckEvents(ctx any, fromBlock any, toBlock any) *MockClient_WriteAckEvents_Call { + return &MockClient_WriteAckEvents_Call{Call: _e.mock.On("WriteAckEvents", ctx, fromBlock, toBlock)} +} + +func (_c *MockClient_WriteAckEvents_Call) Run(run func(ctx context.Context, fromBlock uint64, toBlock uint64)) *MockClient_WriteAckEvents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_WriteAckEvents_Call) Return(packetEvents []PacketEvent, err error) *MockClient_WriteAckEvents_Call { + _c.Call.Return(packetEvents, err) + return _c +} + +func (_c *MockClient_WriteAckEvents_Call) RunAndReturn(run func(ctx context.Context, fromBlock uint64, toBlock uint64) ([]PacketEvent, error)) *MockClient_WriteAckEvents_Call { + _c.Call.Return(run) + return _c +} diff --git a/link/internal/chains/evm/chainid.go b/link/internal/chains/evm/chainid.go new file mode 100644 index 000000000..93690465c --- /dev/null +++ b/link/internal/chains/evm/chainid.go @@ -0,0 +1,33 @@ +package evm + +import ( + "math/big" +) + +// ChainIDMismatchError reports a configured numeric chain id that disagrees with +// the node's reported eth_chainId — a permanent misconfiguration. +type ChainIDMismatchError struct { + Configured string + Reported string +} + +func (e *ChainIDMismatchError) Error() string { + return "configured chain id " + e.Configured + " disagrees with eth_chainId " + e.Reported +} + +// CheckChainID fails when a numeric configured chain id disagrees with the node's +// reported numeric chain id. Non-numeric configured ids (labels) are exempt from +// the comparison, so a label-addressed chain passes through unchecked. It is the +// single source of truth for the relayer and attestor chain-identity policy. +func CheckChainID(configured string, node *big.Int) error { + declared, ok := new(big.Int).SetString(configured, 10) + if !ok { + return nil + } + + if declared.Cmp(node) != 0 { + return &ChainIDMismatchError{Configured: configured, Reported: node.String()} + } + + return nil +} diff --git a/link/internal/chains/evm/chainid_test.go b/link/internal/chains/evm/chainid_test.go new file mode 100644 index 000000000..c5375cbc3 --- /dev/null +++ b/link/internal/chains/evm/chainid_test.go @@ -0,0 +1,31 @@ +package evm + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckChainID(t *testing.T) { + t.Run("numericMatch", func(t *testing.T) { + require.NoError(t, CheckChainID("8453", big.NewInt(8453))) + }) + + t.Run("numericMismatch", func(t *testing.T) { + err := CheckChainID("1", big.NewInt(999)) + require.Error(t, err) + + var mismatch *ChainIDMismatchError + require.ErrorAs(t, err, &mismatch) + assert.Equal(t, "1", mismatch.Configured) + assert.Equal(t, "999", mismatch.Reported) + }) + + t.Run("nonNumericLabelExempt", func(t *testing.T) { + // A label configured id carries no numeric identity to compare, so any + // node chain id is accepted. + require.NoError(t, CheckChainID("devnet-local", big.NewInt(31337))) + }) +} diff --git a/link/internal/chains/evm/client.go b/link/internal/chains/evm/client.go new file mode 100644 index 000000000..3c9a5070c --- /dev/null +++ b/link/internal/chains/evm/client.go @@ -0,0 +1,446 @@ +// Package evm implements the chain client for EVM chains. +package evm + +import ( + "context" + "log/slog" + "math/big" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + ethereum "github.com/ethereum/go-ethereum" +) + +// Router event names. +const ( + sendPacketEvent = "SendPacket" + writeAckEvent = "WriteAcknowledgement" + ackPacketEvent = "AckPacket" + timeoutPacketEvent = "TimeoutPacket" +) + +// ETHClient go-ethereum methods used by Client and Submitter. +type ETHClient interface { + bind.ContractFilterer + bind.ContractCaller + + TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + BlockNumber(ctx context.Context) (uint64, error) + SuggestGasTipCap(ctx context.Context) (*big.Int, error) + PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) + NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) + PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error) + EstimateGas(ctx context.Context, call ethereum.CallMsg) (uint64, error) + SendTransaction(ctx context.Context, tx *types.Transaction) error + BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) +} + +// Client implements chains.Client for EVM chains. +type Client struct { + chainID string + routerAddress common.Address + eth ETHClient + router *ics26router.ContractFilterer + routerCaller *ics26router.ContractCaller + routerABI *abi.ABI + logger *slog.Logger +} + +var _ chains.Client = (*Client)(nil) + +// rpcRequestTimeout bounds every individual RPC request. Without it, an +// endpoint that accepts connections but never answers (a paused node, a +// network blackhole) blocks the calling goroutine indefinitely and can wedge +// the whole relay loop; a hung chain must degrade into per-request errors the +// engine retries on its next pass. +const rpcRequestTimeout = 10 * time.Second + +func New(chainID, rpcURL, ics26RouterAddress string) (*Client, error) { + rpcClient, err := rpc.DialOptions( + context.Background(), + rpcURL, + rpc.WithHTTPClient(&http.Client{Timeout: rpcRequestTimeout}), + ) + if err != nil { + return nil, errors.Wrapf(err, "dialing rpc for chain %s", chainID) + } + + return NewWithClient(chainID, ethclient.NewClient(rpcClient), ics26RouterAddress) +} + +func NewWithClient(chainID string, eth ETHClient, ics26RouterAddress string) (*Client, error) { + if !common.IsHexAddress(ics26RouterAddress) { + return nil, errors.Errorf("invalid ics26 router address %q for chain %s", ics26RouterAddress, chainID) + } + + routerAddress := common.HexToAddress(ics26RouterAddress) + + router, err := ics26router.NewContractFilterer(routerAddress, eth) + if err != nil { + return nil, errors.Wrap(err, "creating ics26 router filterer") + } + + routerCaller, err := ics26router.NewContractCaller(routerAddress, eth) + if err != nil { + return nil, errors.Wrap(err, "creating ics26 router caller") + } + + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "getting ics26 router abi") + } + + for _, name := range []string{sendPacketEvent, writeAckEvent, ackPacketEvent, timeoutPacketEvent} { + if _, ok := routerABI.Events[name]; !ok { + return nil, errors.Errorf("event %q not found in ics26 router abi", name) + } + } + + return &Client{ + chainID: chainID, + routerAddress: routerAddress, + eth: eth, + router: router, + routerCaller: routerCaller, + routerABI: routerABI, + logger: slog.With("module", "chains", "chainType", "evm", "chainID", chainID), + }, nil +} + +// Eth returns the underlying ETH client so a shared connection can back a +// Submitter for the same chain. +func (c *Client) Eth() ETHClient { + return c.eth +} + +// ChainID fetches the numeric EVM chain id (eth_chainId). The concrete +// go-ethereum client supports it; a mock ETHClient that does not returns an error. +func (c *Client) ChainID(ctx context.Context) (*big.Int, error) { + caller, ok := c.eth.(interface { + ChainID(context.Context) (*big.Int, error) + }) + if !ok { + return nil, errors.Errorf("eth client for chain %s does not support ChainID", c.chainID) + } + + id, err := caller.ChainID(ctx) + if err != nil { + return nil, errors.Wrapf(err, "fetching eth chain id for chain %s", c.chainID) + } + + return id, nil +} + +// BalanceAt returns the account's native-token balance (wei) at the latest +// block. Used by the low-gas monitor to track relayer signer balances. +func (c *Client) BalanceAt(ctx context.Context, account common.Address) (*big.Int, error) { + balance, err := c.eth.BalanceAt(ctx, account, nil) + if err != nil { + return nil, errors.Wrapf(err, "fetching balance for %s on chain %s", account, c.chainID) + } + + return balance, nil +} + +// Close releases the underlying RPC connection. It is safe to call on a client +// whose ETH transport does not expose Close (e.g. a test mock). +func (c *Client) Close() error { + if closer, ok := c.eth.(interface{ Close() }); ok { + closer.Close() + } + + return nil +} + +// TxPacketEvents parses all packet lifecycle events from a transaction receipt. +func (c *Client) TxPacketEvents(ctx context.Context, rawTxHash []byte) ([]chains.PacketEvent, error) { + if len(rawTxHash) != common.HashLength { + return nil, errors.Errorf("invalid tx hash length %d, expected %d", len(rawTxHash), common.HashLength) + } + + txHash := common.BytesToHash(rawTxHash) + + receipt, err := c.eth.TransactionReceipt(ctx, txHash) + if err != nil { + return nil, errors.Wrapf(err, "getting receipt for tx %s on chain %s", txHash, c.chainID) + } + + if receipt.Status != types.ReceiptStatusSuccessful { + return nil, errors.Wrapf(chains.ErrTxReverted, "tx %s on chain %s", txHash, c.chainID) + } + + var events []chains.PacketEvent + + for _, log := range receipt.Logs { + switch { + case log == nil, len(log.Topics) == 0: + continue + case log.Address != c.routerAddress: + continue + } + + event, ok, errParse := c.parsePacketEvent(*log) + if errParse != nil { + return nil, errors.Wrapf(errParse, "parsing packet event from tx %s on chain %s", txHash, c.chainID) + } + if !ok { + continue + } + + events = append(events, event) + } + + if len(events) == 0 { + return nil, nil + } + + header, err := c.eth.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return nil, errors.Wrapf(err, "getting header %s for tx %s on chain %s", receipt.BlockNumber, txHash, c.chainID) + } + + blockNumber := receipt.BlockNumber.Uint64() + blockTime := blockTimeFor(header) + hashHex := txHash.Hex() + for i := range events { + events[i].Height = blockNumber + events[i].BlockTime = blockTime + events[i].TxHash = hashHex + } + + return events, nil +} + +// SendPacketEvents range-scans the router for SendPacket events. +func (c *Client) SendPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]chains.PacketEvent, error) { + return c.rangeScan(ctx, fromBlock, toBlock, sendPacketEvent) +} + +// WriteAckEvents range-scans the router for WriteAcknowledgement events. +func (c *Client) WriteAckEvents(ctx context.Context, fromBlock, toBlock uint64) ([]chains.PacketEvent, error) { + return c.rangeScan(ctx, fromBlock, toBlock, writeAckEvent) +} + +// AckPacketEvents range-scans the router for AckPacket events. +func (c *Client) AckPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]chains.PacketEvent, error) { + return c.rangeScan(ctx, fromBlock, toBlock, ackPacketEvent) +} + +// TimeoutPacketEvents range-scans the router for TimeoutPacket events. +func (c *Client) TimeoutPacketEvents(ctx context.Context, fromBlock, toBlock uint64) ([]chains.PacketEvent, error) { + return c.rangeScan(ctx, fromBlock, toBlock, timeoutPacketEvent) +} + +// rangeScan filters the router logs for a single event over a block range and +// enriches the resulting events with their block timestamps. +func (c *Client) rangeScan( + ctx context.Context, + fromBlock, toBlock uint64, + eventName string, +) ([]chains.PacketEvent, error) { + query := ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromBlock), + ToBlock: new(big.Int).SetUint64(toBlock), + Addresses: []common.Address{c.routerAddress}, + Topics: [][]common.Hash{{c.routerABI.Events[eventName].ID}}, + } + + logs, err := c.eth.FilterLogs(ctx, query) + if err != nil { + return nil, errors.Wrapf( + err, + "filtering %s logs on chain %s from %d to %d", + eventName, + c.chainID, + fromBlock, + toBlock, + ) + } + + events := make([]chains.PacketEvent, 0, len(logs)) + for i := range logs { + event, ok, errParse := c.parsePacketEvent(logs[i]) + if errParse != nil { + return nil, errors.Wrapf(errParse, "parsing %s log on chain %s", eventName, c.chainID) + } + if !ok { + continue + } + event.Height = logs[i].BlockNumber + event.TxHash = logs[i].TxHash.Hex() + events = append(events, event) + } + + if err := c.enrichBlockTimes(ctx, events); err != nil { + return nil, err + } + + return events, nil +} + +// enrichBlockTimes fills BlockTime for each event, fetching each unique block +// header at most once. +func (c *Client) enrichBlockTimes(ctx context.Context, events []chains.PacketEvent) error { + cache := make(map[uint64]time.Time) + for i := range events { + height := events[i].Height + blockTime, ok := cache[height] + if !ok { + header, err := c.eth.HeaderByNumber(ctx, new(big.Int).SetUint64(height)) + if err != nil { + return errors.Wrapf(err, "getting header %d on chain %s", height, c.chainID) + } + blockTime = blockTimeFor(header) + cache[height] = blockTime + } + events[i].BlockTime = blockTime + } + + return nil +} + +// parsePacketEvent parses a single router log into a PacketEvent. The bool is +// false for logs that are not one of the tracked packet lifecycle events. +func (c *Client) parsePacketEvent(log types.Log) (chains.PacketEvent, bool, error) { + if len(log.Topics) == 0 { + return chains.PacketEvent{}, false, nil + } + + switch log.Topics[0] { + case c.routerABI.Events[sendPacketEvent].ID: + parsed, err := c.router.ParseSendPacket(log) + if err != nil { + return chains.PacketEvent{}, false, err + } + return chains.PacketEvent{Kind: chains.KindSendPacket, Packet: toPacket(parsed.Packet)}, true, nil + + case c.routerABI.Events[writeAckEvent].ID: + parsed, err := c.router.ParseWriteAcknowledgement(log) + if err != nil { + return chains.PacketEvent{}, false, err + } + return chains.PacketEvent{ + Kind: chains.KindWriteAck, + Packet: toPacket(parsed.Packet), + Acks: parsed.Acknowledgements, + }, true, nil + + case c.routerABI.Events[ackPacketEvent].ID: + parsed, err := c.router.ParseAckPacket(log) + if err != nil { + return chains.PacketEvent{}, false, err + } + return chains.PacketEvent{ + Kind: chains.KindAckPacket, + Packet: toPacket(parsed.Packet), + Acks: [][]byte{parsed.Acknowledgement}, + }, true, nil + + case c.routerABI.Events[timeoutPacketEvent].ID: + parsed, err := c.router.ParseTimeoutPacket(log) + if err != nil { + return chains.PacketEvent{}, false, err + } + return chains.PacketEvent{Kind: chains.KindTimeoutPacket, Packet: toPacket(parsed.Packet)}, true, nil + + default: + return chains.PacketEvent{}, false, nil + } +} + +// LatestHeight returns the latest block number. +func (c *Client) LatestHeight(ctx context.Context) (uint64, error) { + height, err := c.eth.BlockNumber(ctx) + if err != nil { + return 0, errors.Wrapf(err, "getting latest height on chain %s", c.chainID) + } + + return height, nil +} + +// FinalizedHeight returns the finalized block number. A nil offset uses the +// chain's native "finalized" tag; a set offset returns latest-offset (saturating). +func (c *Client) FinalizedHeight(ctx context.Context, finalityOffset *uint64) (uint64, error) { + if finalityOffset == nil { + header, err := c.eth.HeaderByNumber(ctx, big.NewInt(int64(rpc.FinalizedBlockNumber))) + if err != nil { + return 0, errors.Wrapf(err, "getting finalized header on chain %s", c.chainID) + } + + return header.Number.Uint64(), nil + } + + latest, err := c.eth.BlockNumber(ctx) + if err != nil { + return 0, errors.Wrapf(err, "getting latest height on chain %s", c.chainID) + } + + if *finalityOffset >= latest { + return 0, nil + } + + return latest - *finalityOffset, nil +} + +// HeaderTimestamp returns the block time at the given height. +func (c *Client) HeaderTimestamp(ctx context.Context, height uint64) (time.Time, error) { + header, err := c.eth.HeaderByNumber(ctx, new(big.Int).SetUint64(height)) + if err != nil { + return time.Time{}, errors.Wrapf(err, "getting header %d on chain %s", height, c.chainID) + } + + return blockTimeFor(header), nil +} + +// GetCommitment reads the commitment at hashedPath pinned to the given block +// height. A zero value means the commitment is absent. +func (c *Client) GetCommitment(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error) { + opts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(height), + } + + commitment, err := c.routerCaller.GetCommitment(opts, hashedPath) + if err != nil { + return [32]byte{}, errors.Wrapf(err, "getting commitment at height %d on chain %s", height, c.chainID) + } + + return commitment, nil +} + +func toPacket(packet ics26router.IICS26RouterMsgsPacket) chains.Packet { + payloads := make([]chains.Payload, len(packet.Payloads)) + for i, payload := range packet.Payloads { + payloads[i] = chains.Payload{ + SourcePort: payload.SourcePort, + DestPort: payload.DestPort, + Version: payload.Version, + Encoding: payload.Encoding, + Value: payload.Value, + } + } + + return chains.Packet{ + Sequence: packet.Sequence, + SourceClient: packet.SourceClient, + DestClient: packet.DestClient, + TimeoutTimestamp: packet.TimeoutTimestamp, + Payloads: payloads, + } +} + +func blockTimeFor(header *types.Header) time.Time { + return time.Unix(int64(header.Time), 0).UTC() //nolint:gosec // block times fit in int64 +} diff --git a/link/internal/chains/evm/client_events_test.go b/link/internal/chains/evm/client_events_test.go new file mode 100644 index 000000000..409f251f7 --- /dev/null +++ b/link/internal/chains/evm/client_events_test.go @@ -0,0 +1,276 @@ +package evm + +import ( + "context" + "math/big" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" +) + +// eventLog ABI-encodes a router event log with the given non-indexed args. +func eventLog(t *testing.T, address common.Address, eventName, clientID string, sequence uint64, args ...any) *types.Log { + t.Helper() + + routerABI, err := ics26router.ContractMetaData.GetAbi() + require.NoError(t, err) + + event := routerABI.Events[eventName] + + data, err := event.Inputs.NonIndexed().Pack(args...) + require.NoError(t, err) + + return &types.Log{ + Address: address, + Topics: []common.Hash{ + event.ID, + crypto.Keccak256Hash([]byte(clientID)), + common.BigToHash(new(big.Int).SetUint64(sequence)), + }, + Data: data, + } +} + +func TestTxPacketEventsAllKinds(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + addr := common.HexToAddress(routerAddress) + packet := testPacket() + acks := [][]byte{{0x01, 0x02}} + + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(100), + Logs: []*types.Log{ + eventLog(t, addr, sendPacketEvent, packet.SourceClient, packet.Sequence, packet), + eventLog(t, addr, writeAckEvent, packet.DestClient, packet.Sequence, packet, acks), + eventLog(t, addr, ackPacketEvent, packet.SourceClient, packet.Sequence, packet, acks[0]), + eventLog(t, addr, timeoutPacketEvent, packet.SourceClient, packet.Sequence, packet), + }, + } + + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(receipt, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(100)).Return(&types.Header{Time: 1752000000}, nil).Once() + + events, err := client.TxPacketEvents(ctx, txHash.Bytes()) + require.NoError(t, err) + require.Len(t, events, 4) + + for _, event := range events { + assert.Equal(t, uint64(100), event.Height) + assert.Equal(t, time.Unix(1752000000, 0).UTC(), event.BlockTime) + assert.Equal(t, uint64(42), event.Packet.Sequence) + } + + assert.Equal(t, chains.KindSendPacket, events[0].Kind) + assert.Nil(t, events[0].Acks) + + assert.Equal(t, chains.KindWriteAck, events[1].Kind) + assert.Equal(t, acks, events[1].Acks) + + assert.Equal(t, chains.KindAckPacket, events[2].Kind) + assert.Equal(t, acks, events[2].Acks) + + assert.Equal(t, chains.KindTimeoutPacket, events[3].Kind) + assert.Nil(t, events[3].Acks) +} + +func TestSendPacketEventsRangeScan(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + addr := common.HexToAddress(routerAddress) + packet := testPacket() + + log1 := eventLog(t, addr, sendPacketEvent, packet.SourceClient, packet.Sequence, packet) + log1.BlockNumber = 10 + log2 := eventLog(t, addr, sendPacketEvent, packet.SourceClient, packet.Sequence+1, packet) + log2.BlockNumber = 12 + + var gotQuery ethereum.FilterQuery + eth.EXPECT().FilterLogs(ctx, mock.Anything). + Run(func(_ context.Context, q ethereum.FilterQuery) { gotQuery = q }). + Return([]types.Log{*log1, *log2}, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(10)).Return(&types.Header{Time: 1000}, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(12)).Return(&types.Header{Time: 1200}, nil).Once() + + events, err := client.SendPacketEvents(ctx, 5, 20) + require.NoError(t, err) + require.Len(t, events, 2) + + // query pins the correct range, address and event topic + assert.Equal(t, big.NewInt(5), gotQuery.FromBlock) + assert.Equal(t, big.NewInt(20), gotQuery.ToBlock) + require.Equal(t, []common.Address{addr}, gotQuery.Addresses) + routerABI, err := ics26router.ContractMetaData.GetAbi() + require.NoError(t, err) + require.Len(t, gotQuery.Topics, 1) + require.Equal(t, []common.Hash{routerABI.Events[sendPacketEvent].ID}, gotQuery.Topics[0]) + + assert.Equal(t, uint64(10), events[0].Height) + assert.Equal(t, time.Unix(1000, 0).UTC(), events[0].BlockTime) + assert.Equal(t, chains.KindSendPacket, events[0].Kind) + assert.Equal(t, uint64(12), events[1].Height) + assert.Equal(t, time.Unix(1200, 0).UTC(), events[1].BlockTime) +} + +func TestWriteAckEventsRangeScanCachesHeader(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + addr := common.HexToAddress(routerAddress) + packet := testPacket() + acks := [][]byte{{0xaa}} + + // two events in the same block: header fetched once + log1 := eventLog(t, addr, writeAckEvent, packet.DestClient, 1, packet, acks) + log1.BlockNumber = 7 + log2 := eventLog(t, addr, writeAckEvent, packet.DestClient, 2, packet, acks) + log2.BlockNumber = 7 + + eth.EXPECT().FilterLogs(ctx, mock.Anything).Return([]types.Log{*log1, *log2}, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(7)).Return(&types.Header{Time: 700}, nil).Once() + + events, err := client.WriteAckEvents(ctx, 0, 10) + require.NoError(t, err) + require.Len(t, events, 2) + for _, event := range events { + assert.Equal(t, chains.KindWriteAck, event.Kind) + assert.Equal(t, acks, event.Acks) + assert.Equal(t, time.Unix(700, 0).UTC(), event.BlockTime) + } +} + +func TestLatestHeight(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().BlockNumber(ctx).Return(uint64(1234), nil).Once() + + height, err := client.LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(1234), height) +} + +func TestFinalizedHeight(t *testing.T) { + t.Run("nativeTag", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(int64(rpc.FinalizedBlockNumber))). + Return(&types.Header{Number: big.NewInt(900)}, nil).Once() + + height, err := client.FinalizedHeight(ctx, nil) + require.NoError(t, err) + assert.Equal(t, uint64(900), height) + }) + + t.Run("offset", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().BlockNumber(ctx).Return(uint64(1000), nil).Once() + + offset := uint64(10) + height, err := client.FinalizedHeight(ctx, &offset) + require.NoError(t, err) + assert.Equal(t, uint64(990), height) + }) + + t.Run("offsetSaturates", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().BlockNumber(ctx).Return(uint64(5), nil).Once() + + offset := uint64(10) + height, err := client.FinalizedHeight(ctx, &offset) + require.NoError(t, err) + assert.Equal(t, uint64(0), height) + }) +} + +func TestHeaderTimestamp(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(50)).Return(&types.Header{Time: 1752000000}, nil).Once() + + ts, err := client.HeaderTimestamp(ctx, 50) + require.NoError(t, err) + assert.Equal(t, time.Unix(1752000000, 0).UTC(), ts) +} + +func TestGetCommitmentPinsHeight(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + var hashedPath [32]byte + copy(hashedPath[:], []byte("path")) + + var want [32]byte + copy(want[:], []byte("commitment-value")) + + // abi-encode the bytes32 return value; assert the call is pinned to height 77. + bytes32Type, err := abi.NewType("bytes32", "", nil) + require.NoError(t, err) + output, err := abi.Arguments{{Type: bytes32Type}}.Pack(want) + require.NoError(t, err) + + var gotBlock *big.Int + eth.EXPECT().CallContract(ctx, mock.Anything, mock.Anything). + Run(func(_ context.Context, _ ethereum.CallMsg, blockNumber *big.Int) { gotBlock = blockNumber }). + Return(output, nil).Once() + + got, err := client.GetCommitment(ctx, hashedPath, 77) + require.NoError(t, err) + assert.Equal(t, want, got) + require.NotNil(t, gotBlock) + assert.Equal(t, big.NewInt(77), gotBlock) +} + +func TestGetCommitmentAbsent(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + // zero bytes32 return value means the commitment is absent. + output := make([]byte, 32) + eth.EXPECT().CallContract(ctx, mock.Anything, mock.Anything).Return(output, nil).Once() + + got, err := client.GetCommitment(ctx, [32]byte{}, 5) + require.NoError(t, err) + assert.Equal(t, [32]byte{}, got) +} diff --git a/link/internal/chains/evm/client_test.go b/link/internal/chains/evm/client_test.go new file mode 100644 index 000000000..613b7eee3 --- /dev/null +++ b/link/internal/chains/evm/client_test.go @@ -0,0 +1,200 @@ +package evm + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" +) + +const ( + chainIDEth = "1" + routerAddress = "0xe20BccD900Fa1B48f46F5a483d9De063b07eDFCC" +) + +var txHash = common.HexToHash("0x60016c34c02278856c81a41ce857ac4bb837a2f4a13c95207e08cbc9e8f2b706") + +func testPacket() ics26router.IICS26RouterMsgsPacket { + return ics26router.IICS26RouterMsgsPacket{ + Sequence: 42, + SourceClient: "base-0", + DestClient: "ethereum-0", + TimeoutTimestamp: 1780000000, + Payloads: []ics26router.IICS26RouterMsgsPayload{ + { + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte{0xde, 0xad, 0xbe, 0xef}, + }, + }, + } +} + +// sendPacketLog ABI-encodes a SendPacket event log as the router contract emits it. +func sendPacketLog(t *testing.T, address common.Address, packet ics26router.IICS26RouterMsgsPacket) *types.Log { + t.Helper() + + routerABI, err := ics26router.ContractMetaData.GetAbi() + require.NoError(t, err) + + event := routerABI.Events[sendPacketEvent] + + data, err := event.Inputs.NonIndexed().Pack(packet) + require.NoError(t, err) + + return &types.Log{ + Address: address, + Topics: []common.Hash{ + event.ID, + crypto.Keccak256Hash([]byte(packet.SourceClient)), + common.BigToHash(new(big.Int).SetUint64(packet.Sequence)), + }, + Data: data, + } +} + +func TestTxPacketEvents(t *testing.T) { + t.Run("parsesSendPacket", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + packet := testPacket() + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(100), + Logs: []*types.Log{ + // unrelated contract emitting the same event is ignored + sendPacketLog(t, common.HexToAddress("0x0000000000000000000000000000000000000bad"), packet), + // unrelated log with no topics is ignored + {Address: common.HexToAddress(routerAddress)}, + sendPacketLog(t, common.HexToAddress(routerAddress), packet), + }, + } + + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(receipt, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(100)).Return(&types.Header{Time: 1752000000}, nil).Once() + + events, err := client.TxPacketEvents(ctx, txHash.Bytes()) + + require.NoError(t, err) + require.Len(t, events, 1) + + event := events[0] + assert.Equal(t, chains.KindSendPacket, event.Kind) + assert.Equal(t, uint64(100), event.Height) + assert.Equal(t, time.Unix(1752000000, 0).UTC(), event.BlockTime) + assert.Equal(t, uint64(42), event.Packet.Sequence) + assert.Equal(t, "base-0", event.Packet.SourceClient) + assert.Equal(t, "ethereum-0", event.Packet.DestClient) + assert.Equal(t, uint64(1780000000), event.Packet.TimeoutTimestamp) + require.Len(t, event.Packet.Payloads, 1) + assert.Equal(t, "transfer", event.Packet.Payloads[0].SourcePort) + assert.Equal(t, []byte{0xde, 0xad, 0xbe, 0xef}, event.Packet.Payloads[0].Value) + }) + + t.Run("noSendPackets", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + receipt := &types.Receipt{Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(100), Logs: []*types.Log{}} + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(receipt, nil).Once() + // no HeaderByNumber expectation: it must not be called + + events, err := client.TxPacketEvents(ctx, txHash.Bytes()) + + require.NoError(t, err) + assert.Empty(t, events) + }) + + t.Run("revertedTx", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + receipt := &types.Receipt{Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(100)} + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(receipt, nil).Once() + + _, err = client.TxPacketEvents(ctx, txHash.Bytes()) + + require.ErrorIs(t, err, chains.ErrTxReverted) + }) + + t.Run("headerError", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(100), + Logs: []*types.Log{sendPacketLog(t, common.HexToAddress(routerAddress), testPacket())}, + } + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(receipt, nil).Once() + eth.EXPECT().HeaderByNumber(ctx, big.NewInt(100)).Return(nil, errors.New("rpc down")).Once() + + _, err = client.TxPacketEvents(ctx, txHash.Bytes()) + + require.ErrorContains(t, err, "getting header") + }) + + t.Run("receiptError", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(nil, assert.AnError).Once() + + _, err = client.TxPacketEvents(ctx, txHash.Bytes()) + + require.ErrorContains(t, err, "getting receipt") + }) + + t.Run("invalidHashLength", func(t *testing.T) { + client, err := NewWithClient(chainIDEth, NewMockETHClient(t), routerAddress) + require.NoError(t, err) + + _, err = client.TxPacketEvents(context.Background(), []byte{0xde, 0xad}) + + require.ErrorContains(t, err, "invalid tx hash length") + }) + + t.Run("invalidRouterAddress", func(t *testing.T) { + _, err := NewWithClient(chainIDEth, NewMockETHClient(t), "not-an-address") + + require.ErrorContains(t, err, "invalid ics26 router address") + }) + +} + +func TestBalanceAt(t *testing.T) { + account := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + + eth := NewMockETHClient(t) + eth.EXPECT().BalanceAt(context.Background(), account, (*big.Int)(nil)).Return(big.NewInt(1234), nil).Once() + client, err := NewWithClient(chainIDEth, eth, routerAddress) + require.NoError(t, err) + + balance, err := client.BalanceAt(context.Background(), account) + + require.NoError(t, err) + assert.Equal(t, big.NewInt(1234), balance) +} diff --git a/link/internal/chains/evm/evm.mock.go b/link/internal/chains/evm/evm.mock.go new file mode 100644 index 000000000..8d42581de --- /dev/null +++ b/link/internal/chains/evm/evm.mock.go @@ -0,0 +1,992 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package evm + +import ( + "context" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + mock "github.com/stretchr/testify/mock" + "math/big" +) + +// NewMockETHClient creates a new instance of MockETHClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockETHClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockETHClient { + mock := &MockETHClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockETHClient is an autogenerated mock type for the ETHClient type +type MockETHClient struct { + mock.Mock +} + +type MockETHClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockETHClient) EXPECT() *MockETHClient_Expecter { + return &MockETHClient_Expecter{mock: &_m.Mock} +} + +// BalanceAt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) { + ret := _mock.Called(ctx, account, blockNumber) + + if len(ret) == 0 { + panic("no return value specified for BalanceAt") + } + + var r0 *big.Int + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) (*big.Int, error)); ok { + return returnFunc(ctx, account, blockNumber) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) *big.Int); ok { + r0 = returnFunc(ctx, account, blockNumber) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*big.Int) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Address, *big.Int) error); ok { + r1 = returnFunc(ctx, account, blockNumber) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_BalanceAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BalanceAt' +type MockETHClient_BalanceAt_Call struct { + *mock.Call +} + +// BalanceAt is a helper method to define mock.On call +// - ctx context.Context +// - account common.Address +// - blockNumber *big.Int +func (_e *MockETHClient_Expecter) BalanceAt(ctx any, account any, blockNumber any) *MockETHClient_BalanceAt_Call { + return &MockETHClient_BalanceAt_Call{Call: _e.mock.On("BalanceAt", ctx, account, blockNumber)} +} + +func (_c *MockETHClient_BalanceAt_Call) Run(run func(ctx context.Context, account common.Address, blockNumber *big.Int)) *MockETHClient_BalanceAt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Address + if args[1] != nil { + arg1 = args[1].(common.Address) + } + var arg2 *big.Int + if args[2] != nil { + arg2 = args[2].(*big.Int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockETHClient_BalanceAt_Call) Return(intParam *big.Int, err error) *MockETHClient_BalanceAt_Call { + _c.Call.Return(intParam, err) + return _c +} + +func (_c *MockETHClient_BalanceAt_Call) RunAndReturn(run func(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error)) *MockETHClient_BalanceAt_Call { + _c.Call.Return(run) + return _c +} + +// BlockNumber provides a mock function for the type MockETHClient +func (_mock *MockETHClient) BlockNumber(ctx context.Context) (uint64, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for BlockNumber") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_BlockNumber_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BlockNumber' +type MockETHClient_BlockNumber_Call struct { + *mock.Call +} + +// BlockNumber is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockETHClient_Expecter) BlockNumber(ctx any) *MockETHClient_BlockNumber_Call { + return &MockETHClient_BlockNumber_Call{Call: _e.mock.On("BlockNumber", ctx)} +} + +func (_c *MockETHClient_BlockNumber_Call) Run(run func(ctx context.Context)) *MockETHClient_BlockNumber_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockETHClient_BlockNumber_Call) Return(v uint64, err error) *MockETHClient_BlockNumber_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockETHClient_BlockNumber_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *MockETHClient_BlockNumber_Call { + _c.Call.Return(run) + return _c +} + +// CallContract provides a mock function for the type MockETHClient +func (_mock *MockETHClient) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + ret := _mock.Called(ctx, call, blockNumber) + + if len(ret) == 0 { + panic("no return value specified for CallContract") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error)); ok { + return returnFunc(ctx, call, blockNumber) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg, *big.Int) []byte); ok { + r0 = returnFunc(ctx, call, blockNumber) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ethereum.CallMsg, *big.Int) error); ok { + r1 = returnFunc(ctx, call, blockNumber) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_CallContract_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallContract' +type MockETHClient_CallContract_Call struct { + *mock.Call +} + +// CallContract is a helper method to define mock.On call +// - ctx context.Context +// - call ethereum.CallMsg +// - blockNumber *big.Int +func (_e *MockETHClient_Expecter) CallContract(ctx any, call any, blockNumber any) *MockETHClient_CallContract_Call { + return &MockETHClient_CallContract_Call{Call: _e.mock.On("CallContract", ctx, call, blockNumber)} +} + +func (_c *MockETHClient_CallContract_Call) Run(run func(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int)) *MockETHClient_CallContract_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ethereum.CallMsg + if args[1] != nil { + arg1 = args[1].(ethereum.CallMsg) + } + var arg2 *big.Int + if args[2] != nil { + arg2 = args[2].(*big.Int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockETHClient_CallContract_Call) Return(bytes []byte, err error) *MockETHClient_CallContract_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *MockETHClient_CallContract_Call) RunAndReturn(run func(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error)) *MockETHClient_CallContract_Call { + _c.Call.Return(run) + return _c +} + +// CodeAt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + ret := _mock.Called(ctx, contract, blockNumber) + + if len(ret) == 0 { + panic("no return value specified for CodeAt") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) ([]byte, error)); ok { + return returnFunc(ctx, contract, blockNumber) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) []byte); ok { + r0 = returnFunc(ctx, contract, blockNumber) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Address, *big.Int) error); ok { + r1 = returnFunc(ctx, contract, blockNumber) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_CodeAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CodeAt' +type MockETHClient_CodeAt_Call struct { + *mock.Call +} + +// CodeAt is a helper method to define mock.On call +// - ctx context.Context +// - contract common.Address +// - blockNumber *big.Int +func (_e *MockETHClient_Expecter) CodeAt(ctx any, contract any, blockNumber any) *MockETHClient_CodeAt_Call { + return &MockETHClient_CodeAt_Call{Call: _e.mock.On("CodeAt", ctx, contract, blockNumber)} +} + +func (_c *MockETHClient_CodeAt_Call) Run(run func(ctx context.Context, contract common.Address, blockNumber *big.Int)) *MockETHClient_CodeAt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Address + if args[1] != nil { + arg1 = args[1].(common.Address) + } + var arg2 *big.Int + if args[2] != nil { + arg2 = args[2].(*big.Int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockETHClient_CodeAt_Call) Return(bytes []byte, err error) *MockETHClient_CodeAt_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *MockETHClient_CodeAt_Call) RunAndReturn(run func(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error)) *MockETHClient_CodeAt_Call { + _c.Call.Return(run) + return _c +} + +// EstimateGas provides a mock function for the type MockETHClient +func (_mock *MockETHClient) EstimateGas(ctx context.Context, call ethereum.CallMsg) (uint64, error) { + ret := _mock.Called(ctx, call) + + if len(ret) == 0 { + panic("no return value specified for EstimateGas") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg) (uint64, error)); ok { + return returnFunc(ctx, call) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg) uint64); ok { + r0 = returnFunc(ctx, call) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ethereum.CallMsg) error); ok { + r1 = returnFunc(ctx, call) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_EstimateGas_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EstimateGas' +type MockETHClient_EstimateGas_Call struct { + *mock.Call +} + +// EstimateGas is a helper method to define mock.On call +// - ctx context.Context +// - call ethereum.CallMsg +func (_e *MockETHClient_Expecter) EstimateGas(ctx any, call any) *MockETHClient_EstimateGas_Call { + return &MockETHClient_EstimateGas_Call{Call: _e.mock.On("EstimateGas", ctx, call)} +} + +func (_c *MockETHClient_EstimateGas_Call) Run(run func(ctx context.Context, call ethereum.CallMsg)) *MockETHClient_EstimateGas_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ethereum.CallMsg + if args[1] != nil { + arg1 = args[1].(ethereum.CallMsg) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_EstimateGas_Call) Return(v uint64, err error) *MockETHClient_EstimateGas_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockETHClient_EstimateGas_Call) RunAndReturn(run func(ctx context.Context, call ethereum.CallMsg) (uint64, error)) *MockETHClient_EstimateGas_Call { + _c.Call.Return(run) + return _c +} + +// FilterLogs provides a mock function for the type MockETHClient +func (_mock *MockETHClient) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) { + ret := _mock.Called(ctx, q) + + if len(ret) == 0 { + panic("no return value specified for FilterLogs") + } + + var r0 []types.Log + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.FilterQuery) ([]types.Log, error)); ok { + return returnFunc(ctx, q) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.FilterQuery) []types.Log); ok { + r0 = returnFunc(ctx, q) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]types.Log) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ethereum.FilterQuery) error); ok { + r1 = returnFunc(ctx, q) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_FilterLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FilterLogs' +type MockETHClient_FilterLogs_Call struct { + *mock.Call +} + +// FilterLogs is a helper method to define mock.On call +// - ctx context.Context +// - q ethereum.FilterQuery +func (_e *MockETHClient_Expecter) FilterLogs(ctx any, q any) *MockETHClient_FilterLogs_Call { + return &MockETHClient_FilterLogs_Call{Call: _e.mock.On("FilterLogs", ctx, q)} +} + +func (_c *MockETHClient_FilterLogs_Call) Run(run func(ctx context.Context, q ethereum.FilterQuery)) *MockETHClient_FilterLogs_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ethereum.FilterQuery + if args[1] != nil { + arg1 = args[1].(ethereum.FilterQuery) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_FilterLogs_Call) Return(logs []types.Log, err error) *MockETHClient_FilterLogs_Call { + _c.Call.Return(logs, err) + return _c +} + +func (_c *MockETHClient_FilterLogs_Call) RunAndReturn(run func(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error)) *MockETHClient_FilterLogs_Call { + _c.Call.Return(run) + return _c +} + +// HeaderByNumber provides a mock function for the type MockETHClient +func (_mock *MockETHClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + ret := _mock.Called(ctx, number) + + if len(ret) == 0 { + panic("no return value specified for HeaderByNumber") + } + + var r0 *types.Header + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *big.Int) (*types.Header, error)); ok { + return returnFunc(ctx, number) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *big.Int) *types.Header); ok { + r0 = returnFunc(ctx, number) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Header) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *big.Int) error); ok { + r1 = returnFunc(ctx, number) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_HeaderByNumber_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HeaderByNumber' +type MockETHClient_HeaderByNumber_Call struct { + *mock.Call +} + +// HeaderByNumber is a helper method to define mock.On call +// - ctx context.Context +// - number *big.Int +func (_e *MockETHClient_Expecter) HeaderByNumber(ctx any, number any) *MockETHClient_HeaderByNumber_Call { + return &MockETHClient_HeaderByNumber_Call{Call: _e.mock.On("HeaderByNumber", ctx, number)} +} + +func (_c *MockETHClient_HeaderByNumber_Call) Run(run func(ctx context.Context, number *big.Int)) *MockETHClient_HeaderByNumber_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *big.Int + if args[1] != nil { + arg1 = args[1].(*big.Int) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_HeaderByNumber_Call) Return(header *types.Header, err error) *MockETHClient_HeaderByNumber_Call { + _c.Call.Return(header, err) + return _c +} + +func (_c *MockETHClient_HeaderByNumber_Call) RunAndReturn(run func(ctx context.Context, number *big.Int) (*types.Header, error)) *MockETHClient_HeaderByNumber_Call { + _c.Call.Return(run) + return _c +} + +// NonceAt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { + ret := _mock.Called(ctx, account, blockNumber) + + if len(ret) == 0 { + panic("no return value specified for NonceAt") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) (uint64, error)); ok { + return returnFunc(ctx, account, blockNumber) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) uint64); ok { + r0 = returnFunc(ctx, account, blockNumber) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Address, *big.Int) error); ok { + r1 = returnFunc(ctx, account, blockNumber) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_NonceAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NonceAt' +type MockETHClient_NonceAt_Call struct { + *mock.Call +} + +// NonceAt is a helper method to define mock.On call +// - ctx context.Context +// - account common.Address +// - blockNumber *big.Int +func (_e *MockETHClient_Expecter) NonceAt(ctx any, account any, blockNumber any) *MockETHClient_NonceAt_Call { + return &MockETHClient_NonceAt_Call{Call: _e.mock.On("NonceAt", ctx, account, blockNumber)} +} + +func (_c *MockETHClient_NonceAt_Call) Run(run func(ctx context.Context, account common.Address, blockNumber *big.Int)) *MockETHClient_NonceAt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Address + if args[1] != nil { + arg1 = args[1].(common.Address) + } + var arg2 *big.Int + if args[2] != nil { + arg2 = args[2].(*big.Int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockETHClient_NonceAt_Call) Return(v uint64, err error) *MockETHClient_NonceAt_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockETHClient_NonceAt_Call) RunAndReturn(run func(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error)) *MockETHClient_NonceAt_Call { + _c.Call.Return(run) + return _c +} + +// PendingCodeAt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error) { + ret := _mock.Called(ctx, account) + + if len(ret) == 0 { + panic("no return value specified for PendingCodeAt") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address) ([]byte, error)); ok { + return returnFunc(ctx, account) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address) []byte); ok { + r0 = returnFunc(ctx, account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Address) error); ok { + r1 = returnFunc(ctx, account) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_PendingCodeAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PendingCodeAt' +type MockETHClient_PendingCodeAt_Call struct { + *mock.Call +} + +// PendingCodeAt is a helper method to define mock.On call +// - ctx context.Context +// - account common.Address +func (_e *MockETHClient_Expecter) PendingCodeAt(ctx any, account any) *MockETHClient_PendingCodeAt_Call { + return &MockETHClient_PendingCodeAt_Call{Call: _e.mock.On("PendingCodeAt", ctx, account)} +} + +func (_c *MockETHClient_PendingCodeAt_Call) Run(run func(ctx context.Context, account common.Address)) *MockETHClient_PendingCodeAt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Address + if args[1] != nil { + arg1 = args[1].(common.Address) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_PendingCodeAt_Call) Return(bytes []byte, err error) *MockETHClient_PendingCodeAt_Call { + _c.Call.Return(bytes, err) + return _c +} + +func (_c *MockETHClient_PendingCodeAt_Call) RunAndReturn(run func(ctx context.Context, account common.Address) ([]byte, error)) *MockETHClient_PendingCodeAt_Call { + _c.Call.Return(run) + return _c +} + +// PendingNonceAt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { + ret := _mock.Called(ctx, account) + + if len(ret) == 0 { + panic("no return value specified for PendingNonceAt") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address) (uint64, error)); ok { + return returnFunc(ctx, account) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Address) uint64); ok { + r0 = returnFunc(ctx, account) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Address) error); ok { + r1 = returnFunc(ctx, account) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_PendingNonceAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PendingNonceAt' +type MockETHClient_PendingNonceAt_Call struct { + *mock.Call +} + +// PendingNonceAt is a helper method to define mock.On call +// - ctx context.Context +// - account common.Address +func (_e *MockETHClient_Expecter) PendingNonceAt(ctx any, account any) *MockETHClient_PendingNonceAt_Call { + return &MockETHClient_PendingNonceAt_Call{Call: _e.mock.On("PendingNonceAt", ctx, account)} +} + +func (_c *MockETHClient_PendingNonceAt_Call) Run(run func(ctx context.Context, account common.Address)) *MockETHClient_PendingNonceAt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Address + if args[1] != nil { + arg1 = args[1].(common.Address) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_PendingNonceAt_Call) Return(v uint64, err error) *MockETHClient_PendingNonceAt_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockETHClient_PendingNonceAt_Call) RunAndReturn(run func(ctx context.Context, account common.Address) (uint64, error)) *MockETHClient_PendingNonceAt_Call { + _c.Call.Return(run) + return _c +} + +// SendTransaction provides a mock function for the type MockETHClient +func (_mock *MockETHClient) SendTransaction(ctx context.Context, tx *types.Transaction) error { + ret := _mock.Called(ctx, tx) + + if len(ret) == 0 { + panic("no return value specified for SendTransaction") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Transaction) error); ok { + r0 = returnFunc(ctx, tx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockETHClient_SendTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendTransaction' +type MockETHClient_SendTransaction_Call struct { + *mock.Call +} + +// SendTransaction is a helper method to define mock.On call +// - ctx context.Context +// - tx *types.Transaction +func (_e *MockETHClient_Expecter) SendTransaction(ctx any, tx any) *MockETHClient_SendTransaction_Call { + return &MockETHClient_SendTransaction_Call{Call: _e.mock.On("SendTransaction", ctx, tx)} +} + +func (_c *MockETHClient_SendTransaction_Call) Run(run func(ctx context.Context, tx *types.Transaction)) *MockETHClient_SendTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *types.Transaction + if args[1] != nil { + arg1 = args[1].(*types.Transaction) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_SendTransaction_Call) Return(err error) *MockETHClient_SendTransaction_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockETHClient_SendTransaction_Call) RunAndReturn(run func(ctx context.Context, tx *types.Transaction) error) *MockETHClient_SendTransaction_Call { + _c.Call.Return(run) + return _c +} + +// SubscribeFilterLogs provides a mock function for the type MockETHClient +func (_mock *MockETHClient) SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) { + ret := _mock.Called(ctx, q, ch) + + if len(ret) == 0 { + panic("no return value specified for SubscribeFilterLogs") + } + + var r0 ethereum.Subscription + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.FilterQuery, chan<- types.Log) (ethereum.Subscription, error)); ok { + return returnFunc(ctx, q, ch) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ethereum.FilterQuery, chan<- types.Log) ethereum.Subscription); ok { + r0 = returnFunc(ctx, q, ch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ethereum.Subscription) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ethereum.FilterQuery, chan<- types.Log) error); ok { + r1 = returnFunc(ctx, q, ch) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_SubscribeFilterLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeFilterLogs' +type MockETHClient_SubscribeFilterLogs_Call struct { + *mock.Call +} + +// SubscribeFilterLogs is a helper method to define mock.On call +// - ctx context.Context +// - q ethereum.FilterQuery +// - ch chan<- types.Log +func (_e *MockETHClient_Expecter) SubscribeFilterLogs(ctx any, q any, ch any) *MockETHClient_SubscribeFilterLogs_Call { + return &MockETHClient_SubscribeFilterLogs_Call{Call: _e.mock.On("SubscribeFilterLogs", ctx, q, ch)} +} + +func (_c *MockETHClient_SubscribeFilterLogs_Call) Run(run func(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log)) *MockETHClient_SubscribeFilterLogs_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ethereum.FilterQuery + if args[1] != nil { + arg1 = args[1].(ethereum.FilterQuery) + } + var arg2 chan<- types.Log + if args[2] != nil { + arg2 = args[2].(chan<- types.Log) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockETHClient_SubscribeFilterLogs_Call) Return(subscription ethereum.Subscription, err error) *MockETHClient_SubscribeFilterLogs_Call { + _c.Call.Return(subscription, err) + return _c +} + +func (_c *MockETHClient_SubscribeFilterLogs_Call) RunAndReturn(run func(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error)) *MockETHClient_SubscribeFilterLogs_Call { + _c.Call.Return(run) + return _c +} + +// SuggestGasTipCap provides a mock function for the type MockETHClient +func (_mock *MockETHClient) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for SuggestGasTipCap") + } + + var r0 *big.Int + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (*big.Int, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) *big.Int); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*big.Int) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_SuggestGasTipCap_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuggestGasTipCap' +type MockETHClient_SuggestGasTipCap_Call struct { + *mock.Call +} + +// SuggestGasTipCap is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockETHClient_Expecter) SuggestGasTipCap(ctx any) *MockETHClient_SuggestGasTipCap_Call { + return &MockETHClient_SuggestGasTipCap_Call{Call: _e.mock.On("SuggestGasTipCap", ctx)} +} + +func (_c *MockETHClient_SuggestGasTipCap_Call) Run(run func(ctx context.Context)) *MockETHClient_SuggestGasTipCap_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockETHClient_SuggestGasTipCap_Call) Return(intParam *big.Int, err error) *MockETHClient_SuggestGasTipCap_Call { + _c.Call.Return(intParam, err) + return _c +} + +func (_c *MockETHClient_SuggestGasTipCap_Call) RunAndReturn(run func(ctx context.Context) (*big.Int, error)) *MockETHClient_SuggestGasTipCap_Call { + _c.Call.Return(run) + return _c +} + +// TransactionReceipt provides a mock function for the type MockETHClient +func (_mock *MockETHClient) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + ret := _mock.Called(ctx, txHash) + + if len(ret) == 0 { + panic("no return value specified for TransactionReceipt") + } + + var r0 *types.Receipt + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Hash) (*types.Receipt, error)); ok { + return returnFunc(ctx, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, common.Hash) *types.Receipt); ok { + r0 = returnFunc(ctx, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Receipt) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, common.Hash) error); ok { + r1 = returnFunc(ctx, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockETHClient_TransactionReceipt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransactionReceipt' +type MockETHClient_TransactionReceipt_Call struct { + *mock.Call +} + +// TransactionReceipt is a helper method to define mock.On call +// - ctx context.Context +// - txHash common.Hash +func (_e *MockETHClient_Expecter) TransactionReceipt(ctx any, txHash any) *MockETHClient_TransactionReceipt_Call { + return &MockETHClient_TransactionReceipt_Call{Call: _e.mock.On("TransactionReceipt", ctx, txHash)} +} + +func (_c *MockETHClient_TransactionReceipt_Call) Run(run func(ctx context.Context, txHash common.Hash)) *MockETHClient_TransactionReceipt_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 common.Hash + if args[1] != nil { + arg1 = args[1].(common.Hash) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockETHClient_TransactionReceipt_Call) Return(receipt *types.Receipt, err error) *MockETHClient_TransactionReceipt_Call { + _c.Call.Return(receipt, err) + return _c +} + +func (_c *MockETHClient_TransactionReceipt_Call) RunAndReturn(run func(ctx context.Context, txHash common.Hash) (*types.Receipt, error)) *MockETHClient_TransactionReceipt_Call { + _c.Call.Return(run) + return _c +} diff --git a/link/internal/chains/evm/submitter.go b/link/internal/chains/evm/submitter.go new file mode 100644 index 000000000..b4af0210b --- /dev/null +++ b/link/internal/chains/evm/submitter.go @@ -0,0 +1,272 @@ +package evm + +import ( + "context" + "log/slog" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + + ethereum "github.com/ethereum/go-ethereum" +) + +const ( + // maxNonceGap is the maximum tolerated gap between the pending and confirmed + // nonce before the submitter deliberately reuses the confirmed nonce to + // replace a stuck transaction. + maxNonceGap = 5 + + // defaultTxSubmissionDelay is the default minimum delay between successful + // submissions from a single submitter. + defaultTxSubmissionDelay = 2 * time.Second +) + +// Signer signs EVM transactions. Both local keys and remote signers implement it. +type Signer interface { + SignTx(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) +} + +// SubmitterConfig configures a Submitter. +type SubmitterConfig struct { + Eth ETHClient + Signer Signer + From common.Address + ChainID *big.Int + + // TxSubmissionDelay minimum delay between successful submissions. + // Zero uses defaultTxSubmissionDelay. + TxSubmissionDelay time.Duration + + // GasFeeCapMultiplier / GasTipCapMultiplier optional gas multipliers. + // Nil applies no adjustment. + GasFeeCapMultiplier *float64 + GasTipCapMultiplier *float64 + + Logger *slog.Logger +} + +// Submitter provides reliable EVM transaction delivery. It serializes signing +// and submission behind a mutex, enforces a minimum inter-submission delay, and +// implements the retry/nonce-fallback semantics of the original relayer. +type Submitter struct { + eth ETHClient + signer Signer + from common.Address + chainID *big.Int + + delay time.Duration + gasFeeCapMultiplier *float64 + gasTipCapMultiplier *float64 + + logger *slog.Logger + + mu sync.Mutex + lastSubmission time.Time +} + +// NewSubmitter creates a Submitter from the given config. +func NewSubmitter(cfg SubmitterConfig) *Submitter { + delay := cfg.TxSubmissionDelay + if delay == 0 { + delay = defaultTxSubmissionDelay + } + + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + + return &Submitter{ + eth: cfg.Eth, + signer: cfg.Signer, + from: cfg.From, + chainID: cfg.ChainID, + delay: delay, + gasFeeCapMultiplier: cfg.GasFeeCapMultiplier, + gasTipCapMultiplier: cfg.GasTipCapMultiplier, + logger: logger.With("module", "submitter", "chainID", cfg.ChainID), + } +} + +// Submit builds, signs and sends a DynamicFeeTx to the given address. It +// serializes concurrent submissions and enforces the configured submission +// delay since the last successful submission. +func (s *Submitter) Submit(ctx context.Context, to common.Address, data []byte) (common.Hash, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.waitForDelay(ctx); err != nil { + return common.Hash{}, err + } + + tx, err := s.buildTx(ctx, to, data) + if err != nil { + return common.Hash{}, err + } + + signed, err := s.signer.SignTx(ctx, tx) + if err != nil { + return common.Hash{}, errors.Wrap(err, "signing transaction") + } + + if err := s.eth.SendTransaction(ctx, signed); err != nil { + return common.Hash{}, errors.Wrap(err, "sending transaction") + } + + s.lastSubmission = time.Now() + + return signed.Hash(), nil +} + +// waitForDelay blocks until the configured delay since the last successful +// submission has elapsed. +func (s *Submitter) waitForDelay(ctx context.Context) error { + if s.lastSubmission.IsZero() { + return nil + } + + elapsed := time.Since(s.lastSubmission) + if elapsed >= s.delay { + return nil + } + + timer := time.NewTimer(s.delay - elapsed) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// buildTx builds an unsigned DynamicFeeTx following the original relayer's gas, +// code and nonce logic. +func (s *Submitter) buildTx(ctx context.Context, to common.Address, data []byte) (*types.Transaction, error) { + header, err := s.eth.HeaderByNumber(ctx, nil) + if err != nil { + return nil, errors.Wrap(err, "getting latest header") + } + if header.BaseFee == nil { + return nil, errors.New("latest header has no base fee") + } + + tipCap, err := s.eth.SuggestGasTipCap(ctx) + if err != nil { + return nil, errors.Wrap(err, "suggesting gas tip cap") + } + + // feeCap = tipCap + 2*baseFee + feeCap := new(big.Int).Add(tipCap, new(big.Int).Mul(big.NewInt(2), header.BaseFee)) + + feeCap = applyMultiplier(feeCap, s.gasFeeCapMultiplier) + tipCap = applyMultiplier(tipCap, s.gasTipCapMultiplier) + + code, err := s.eth.PendingCodeAt(ctx, to) + if err != nil { + return nil, errors.Wrapf(err, "getting code at %s", to) + } + if len(code) == 0 { + return nil, errors.Errorf("no contract code at destination %s", to) + } + + gas, err := s.eth.EstimateGas(ctx, ethereum.CallMsg{ + From: s.from, + To: &to, + GasFeeCap: feeCap, + GasTipCap: tipCap, + Data: data, + }) + if err != nil { + return nil, errors.Wrap(err, "estimating gas") + } + + nonce, err := s.nonce(ctx) + if err != nil { + return nil, err + } + + return types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, + Nonce: nonce, + GasTipCap: tipCap, + GasFeeCap: feeCap, + Gas: gas, + To: &to, + Data: data, + }), nil +} + +// nonce returns the pending nonce, unless the gap between the pending and +// confirmed nonce exceeds maxNonceGap, in which case it returns the confirmed +// nonce to replace a stuck transaction. +func (s *Submitter) nonce(ctx context.Context) (uint64, error) { + pending, err := s.eth.PendingNonceAt(ctx, s.from) + if err != nil { + return 0, errors.Wrap(err, "getting pending nonce") + } + + confirmed, err := s.eth.NonceAt(ctx, s.from, nil) + if err != nil { + return 0, errors.Wrap(err, "getting confirmed nonce") + } + + if pending > confirmed && pending-confirmed > maxNonceGap { + s.logger.Warn("replacing stuck nonce", + "pending", pending, "confirmed", confirmed, "gap", pending-confirmed) + return confirmed, nil + } + + return pending, nil +} + +// Expired reports whether a not-yet-included transaction has sat past its retry +// expiry: the latest block time is after sentAt+expiry. It reads only the latest +// header; inclusion is established separately by the caller via Receipt. +func (s *Submitter) Expired(ctx context.Context, sentAt time.Time, expiry time.Duration) (bool, error) { + header, err := s.eth.HeaderByNumber(ctx, nil) + if err != nil { + return false, errors.Wrap(err, "getting latest header") + } + + return blockTimeFor(header).After(sentAt.Add(expiry)), nil +} + +// From returns the address transactions are submitted from (the relayer address). +func (s *Submitter) From() common.Address { + return s.from +} + +// Receipt fetches a transaction receipt, returning (nil, nil) when the tx is not +// yet included in a block (no receipt). It is the single read that drives +// in-flight resolution: inclusion, execution status, and gas all come from it. +func (s *Submitter) Receipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + receipt, err := s.eth.TransactionReceipt(ctx, txHash) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return nil, nil + } + + return nil, errors.Wrapf(err, "getting receipt for tx %s", txHash) + } + + return receipt, nil +} + +// applyMultiplier scales value by the multiplier using big.Float. A nil +// multiplier returns value unchanged. +func applyMultiplier(value *big.Int, multiplier *float64) *big.Int { + if multiplier == nil { + return value + } + + scaled := new(big.Float).Mul(new(big.Float).SetInt(value), big.NewFloat(*multiplier)) + result, _ := scaled.Int(nil) + + return result +} diff --git a/link/internal/chains/evm/submitter_test.go b/link/internal/chains/evm/submitter_test.go new file mode 100644 index 000000000..0c44752a7 --- /dev/null +++ b/link/internal/chains/evm/submitter_test.go @@ -0,0 +1,218 @@ +package evm + +import ( + "context" + "math/big" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var destAddr = common.HexToAddress("0xe20BccD900Fa1B48f46F5a483d9De063b07eDFCC") + +// recordingSigner captures the last transaction passed to SignTx. +type recordingSigner struct { + signed *types.Transaction +} + +func (s *recordingSigner) SignTx(_ context.Context, tx *types.Transaction) (*types.Transaction, error) { + s.signed = tx + return tx, nil +} + +func ptrFloat(f float64) *float64 { return &f } + +// gasHappyPath sets up the eth mock for a successful buildTx, allowing repeat +// calls. Nonces are configurable. +func gasHappyPath(eth *MockETHClient, pending, confirmed uint64) { + eth.EXPECT().HeaderByNumber(mock.Anything, (*big.Int)(nil)). + Return(&types.Header{BaseFee: big.NewInt(100)}, nil) + eth.EXPECT().SuggestGasTipCap(mock.Anything).Return(big.NewInt(10), nil) + eth.EXPECT().PendingCodeAt(mock.Anything, destAddr).Return([]byte{0x01}, nil) + eth.EXPECT().EstimateGas(mock.Anything, mock.Anything).Return(uint64(21000), nil) + eth.EXPECT().PendingNonceAt(mock.Anything, mock.Anything).Return(pending, nil) + eth.EXPECT().NonceAt(mock.Anything, mock.Anything, (*big.Int)(nil)).Return(confirmed, nil) + eth.EXPECT().SendTransaction(mock.Anything, mock.Anything).Return(nil) +} + +func TestSubmitGasMultipliers(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + signer := &recordingSigner{} + gasHappyPath(eth, 7, 7) + + sub := NewSubmitter(SubmitterConfig{ + Eth: eth, + Signer: signer, + ChainID: big.NewInt(1), + GasFeeCapMultiplier: ptrFloat(1.5), + GasTipCapMultiplier: ptrFloat(2.0), + }) + + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + + // feeCap = (10 + 2*100) * 1.5 = 315; tip = 10 * 2.0 = 20. + require.NotNil(t, signer.signed) + assert.Equal(t, big.NewInt(315), signer.signed.GasFeeCap()) + assert.Equal(t, big.NewInt(20), signer.signed.GasTipCap()) + assert.Equal(t, uint64(21000), signer.signed.Gas()) + assert.Equal(t, uint64(7), signer.signed.Nonce()) +} + +func TestSubmitNoMultipliers(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + signer := &recordingSigner{} + gasHappyPath(eth, 3, 3) + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: signer, ChainID: big.NewInt(1)}) + + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + + // no multipliers: feeCap = 10 + 2*100 = 210; tip = 10. + assert.Equal(t, big.NewInt(210), signer.signed.GasFeeCap()) + assert.Equal(t, big.NewInt(10), signer.signed.GasTipCap()) +} + +func TestSubmitNonceFallback(t *testing.T) { + t.Run("gapExceededUsesConfirmed", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + signer := &recordingSigner{} + gasHappyPath(eth, 20, 10) // gap 10 > 5 + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: signer, ChainID: big.NewInt(1)}) + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + assert.Equal(t, uint64(10), signer.signed.Nonce()) + }) + + t.Run("gapWithinLimitUsesPending", func(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + signer := &recordingSigner{} + gasHappyPath(eth, 15, 10) // gap 5, not > 5 + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: signer, ChainID: big.NewInt(1)}) + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + assert.Equal(t, uint64(15), signer.signed.Nonce()) + }) +} + +func TestSubmitNoCodeAtDestination(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + eth.EXPECT().HeaderByNumber(mock.Anything, (*big.Int)(nil)). + Return(&types.Header{BaseFee: big.NewInt(100)}, nil) + eth.EXPECT().SuggestGasTipCap(mock.Anything).Return(big.NewInt(10), nil) + eth.EXPECT().PendingCodeAt(mock.Anything, destAddr).Return(nil, nil) + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.ErrorContains(t, err, "no contract code") +} + +func TestSubmitDelaySerializes(t *testing.T) { + ctx := context.Background() + eth := NewMockETHClient(t) + gasHappyPath(eth, 1, 1) + + delay := 120 * time.Millisecond + sub := NewSubmitter(SubmitterConfig{ + Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1), TxSubmissionDelay: delay, + }) + + _, err := sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + + start := time.Now() + _, err = sub.Submit(ctx, destAddr, []byte{0x01}) + require.NoError(t, err) + elapsed := time.Since(start) + + assert.GreaterOrEqual(t, elapsed, delay-20*time.Millisecond, + "second submission should wait for the submission delay") +} + +func TestExpired(t *testing.T) { + ctx := context.Background() + sentAt := time.Unix(1000, 0) + expiry := time.Minute + + t.Run("notExpired", func(t *testing.T) { + eth := NewMockETHClient(t) + // latest header time before sentAt+expiry (1000+60=1060) + eth.EXPECT().HeaderByNumber(ctx, (*big.Int)(nil)). + Return(&types.Header{Time: 1010}, nil).Once() + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + expired, err := sub.Expired(ctx, sentAt, expiry) + require.NoError(t, err) + assert.False(t, expired) + }) + + t.Run("expired", func(t *testing.T) { + eth := NewMockETHClient(t) + // latest header time after sentAt+expiry + eth.EXPECT().HeaderByNumber(ctx, (*big.Int)(nil)). + Return(&types.Header{Time: 1100}, nil).Once() + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + expired, err := sub.Expired(ctx, sentAt, expiry) + require.NoError(t, err) + assert.True(t, expired) + }) + + t.Run("headerError", func(t *testing.T) { + eth := NewMockETHClient(t) + eth.EXPECT().HeaderByNumber(ctx, (*big.Int)(nil)).Return(nil, assert.AnError).Once() + + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + _, err := sub.Expired(ctx, sentAt, expiry) + require.Error(t, err) + }) +} + +func TestReceipt(t *testing.T) { + ctx := context.Background() + + t.Run("returnsReceipt", func(t *testing.T) { + eth := NewMockETHClient(t) + want := &types.Receipt{GasUsed: 21_000, EffectiveGasPrice: big.NewInt(1_000_000_000)} + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(want, nil).Once() + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + + got, err := sub.Receipt(ctx, txHash) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("notFoundReturnsNil", func(t *testing.T) { + eth := NewMockETHClient(t) + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(nil, ethereum.NotFound).Once() + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + + got, err := sub.Receipt(ctx, txHash) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("propagatesError", func(t *testing.T) { + eth := NewMockETHClient(t) + eth.EXPECT().TransactionReceipt(ctx, txHash).Return(nil, assert.AnError).Once() + sub := NewSubmitter(SubmitterConfig{Eth: eth, Signer: &recordingSigner{}, ChainID: big.NewInt(1)}) + + got, err := sub.Receipt(ctx, txHash) + require.Error(t, err) + assert.Nil(t, got) + }) +} diff --git a/link/internal/chains/evm/txbuilder.go b/link/internal/chains/evm/txbuilder.go new file mode 100644 index 000000000..4f0ef4a57 --- /dev/null +++ b/link/internal/chains/evm/txbuilder.go @@ -0,0 +1,165 @@ +package evm + +import ( + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" +) + +// Router method names used to build relay calldata. +const ( + updateClientMethod = "updateClient" + recvPacketMethod = "recvPacket" + ackPacketMethod = "ackPacket" + timeoutPacketMethod = "timeoutPacket" + multicallMethod = "multicall" +) + +// TxBuilder builds EVM relay transactions targeting the ICS26 router. +type TxBuilder struct { + routerAddress common.Address + routerABI *abi.ABI +} + +// NewTxBuilder creates a TxBuilder targeting the given ICS26 router address. +func NewTxBuilder(ics26RouterAddress string) (*TxBuilder, error) { + if !common.IsHexAddress(ics26RouterAddress) { + return nil, errors.Errorf("invalid ics26 router address %q", ics26RouterAddress) + } + + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "getting ics26 router abi") + } + + return &TxBuilder{ + routerAddress: common.HexToAddress(ics26RouterAddress), + routerABI: routerABI, + }, nil +} + +// BuildRelayTx encodes a client update followed by the packet relay items into a +// single multicall targeting the router. Calls are ordered updateClient, then +// recv, ack, timeout. It returns the router address and the multicall calldata. +func (b *TxBuilder) BuildRelayTx( + update *chains.ClientUpdate, + items []chains.PacketRelayItem, +) (common.Address, []byte, error) { + if update == nil { + return common.Address{}, nil, errors.New("client update is required") + } + + calls := make([][]byte, 0, len(items)+1) + + updateCall, err := b.routerABI.Pack(updateClientMethod, update.ClientID, update.StateProof) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "packing updateClient") + } + calls = append(calls, updateCall) + + // Group by kind while preserving input order: recv, then ack, then timeout. + for _, kind := range []chains.RelayKind{chains.RelayRecv, chains.RelayAck, chains.RelayTimeout} { + for i := range items { + if items[i].Kind != kind { + continue + } + + call, errPack := b.packItem(items[i]) + if errPack != nil { + return common.Address{}, nil, errors.Wrapf( + errPack, + "packing relay item (seq %d)", + items[i].Packet.Sequence, + ) + } + calls = append(calls, call) + } + } + + data, err := b.routerABI.Pack(multicallMethod, calls) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "packing multicall") + } + + return b.routerAddress, data, nil +} + +func (b *TxBuilder) packItem(item chains.PacketRelayItem) ([]byte, error) { + packet, err := toContractPacket(item.Packet) + if err != nil { + return nil, err + } + + height := proofHeight(item.ProofHeight) + + switch item.Kind { + case chains.RelayRecv: + return b.routerABI.Pack(recvPacketMethod, ics26router.IICS26RouterMsgsMsgRecvPacket{ + Packet: packet, + ProofCommitment: item.Proof, + ProofHeight: height, + }) + + case chains.RelayAck: + if len(item.Acks) == 0 { + return nil, errors.New("ack relay item requires at least one acknowledgement") + } + return b.routerABI.Pack(ackPacketMethod, ics26router.IICS26RouterMsgsMsgAckPacket{ + Packet: packet, + Acknowledgement: item.Acks[0], + ProofAcked: item.Proof, + ProofHeight: height, + }) + + case chains.RelayTimeout: + return b.routerABI.Pack(timeoutPacketMethod, ics26router.IICS26RouterMsgsMsgTimeoutPacket{ + Packet: packet, + ProofTimeout: item.Proof, + ProofHeight: height, + }) + + default: + return nil, errors.Errorf("unknown relay kind %d", item.Kind) + } +} + +// toContractPacket converts a chains.Packet to the router struct. Only +// single-payload packets are supported. +func toContractPacket(packet chains.Packet) (ics26router.IICS26RouterMsgsPacket, error) { + if len(packet.Payloads) != 1 { + return ics26router.IICS26RouterMsgsPacket{}, errors.Errorf( + "only single-payload packets are supported, got %d payloads", len(packet.Payloads), + ) + } + + payloads := make([]ics26router.IICS26RouterMsgsPayload, len(packet.Payloads)) + for i, payload := range packet.Payloads { + payloads[i] = ics26router.IICS26RouterMsgsPayload{ + SourcePort: payload.SourcePort, + DestPort: payload.DestPort, + Version: payload.Version, + Encoding: payload.Encoding, + Value: payload.Value, + } + } + + return ics26router.IICS26RouterMsgsPacket{ + Sequence: packet.Sequence, + SourceClient: packet.SourceClient, + DestClient: packet.DestClient, + TimeoutTimestamp: packet.TimeoutTimestamp, + Payloads: payloads, + }, nil +} + +// proofHeight builds the router Height{revisionNumber: 0, revisionHeight: height}. +func proofHeight(height uint64) ics26router.IICS02ClientMsgsHeight { + return ics26router.IICS02ClientMsgsHeight{ + RevisionNumber: 0, + RevisionHeight: height, + } +} diff --git a/link/internal/chains/evm/txbuilder_test.go b/link/internal/chains/evm/txbuilder_test.go new file mode 100644 index 000000000..5e57ec9ef --- /dev/null +++ b/link/internal/chains/evm/txbuilder_test.go @@ -0,0 +1,153 @@ +package evm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" +) + +func relayPacket(seq uint64) chains.Packet { + return chains.Packet{ + Sequence: seq, + SourceClient: "base-0", + DestClient: "ethereum-0", + TimeoutTimestamp: 1780000000, + Payloads: []chains.Payload{ + { + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/x-solidity-abi", + Value: []byte{0xde, 0xad, 0xbe, 0xef}, + }, + }, + } +} + +func TestBuildRelayTxGolden(t *testing.T) { + builder, err := NewTxBuilder(routerAddress) + require.NoError(t, err) + + routerABI, err := ics26router.ContractMetaData.GetAbi() + require.NoError(t, err) + + update := &chains.ClientUpdate{ClientID: "ethereum-0", StateProof: []byte{0x11, 0x22}} + + // Deliberately interleave kinds to prove grouping/ordering is by kind, not input order. + items := []chains.PacketRelayItem{ + {Kind: chains.RelayTimeout, Packet: relayPacket(3), Proof: []byte{0xcc}, ProofHeight: 30}, + {Kind: chains.RelayRecv, Packet: relayPacket(1), Proof: []byte{0xaa}, ProofHeight: 10}, + {Kind: chains.RelayAck, Packet: relayPacket(2), Acks: [][]byte{{0x99}}, Proof: []byte{0xbb}, ProofHeight: 20}, + {Kind: chains.RelayRecv, Packet: relayPacket(4), Proof: []byte{0xdd}, ProofHeight: 40}, + } + + to, data, err := builder.BuildRelayTx(update, items) + require.NoError(t, err) + assert.Equal(t, builder.routerAddress, to) + + // Outer call is multicall(bytes[]). + require.Equal(t, routerABI.Methods[multicallMethod].ID, data[:4]) + unpacked, err := routerABI.Methods[multicallMethod].Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.Len(t, unpacked, 1) + + calls, ok := unpacked[0].([][]byte) + require.True(t, ok) + require.Len(t, calls, 5) // updateClient + recv + recv + ack + timeout + + // Assert per-call selector and order: updateClient, recv, recv, ack, timeout. + wantMethods := []string{ + updateClientMethod, + recvPacketMethod, + recvPacketMethod, + ackPacketMethod, + timeoutPacketMethod, + } + for i, call := range calls { + method, errID := routerABI.MethodById(call[:4]) + require.NoError(t, errID) + assert.Equalf(t, wantMethods[i], method.Name, "call %d method", i) + } + + // Field round-trip: each call equals a freshly-packed expected message. + packet1, err := toContractPacket(relayPacket(1)) + require.NoError(t, err) + height10 := proofHeight(10) + wantRecv1, err := routerABI.Pack(recvPacketMethod, ics26router.IICS26RouterMsgsMsgRecvPacket{ + Packet: packet1, + ProofCommitment: []byte{0xaa}, + ProofHeight: height10, + }) + require.NoError(t, err) + assert.Equal(t, wantRecv1, calls[1]) + + // Recv items preserve input order (seq 1 before seq 4). + packet4, err := toContractPacket(relayPacket(4)) + require.NoError(t, err) + height40 := proofHeight(40) + wantRecv4, err := routerABI.Pack(recvPacketMethod, ics26router.IICS26RouterMsgsMsgRecvPacket{ + Packet: packet4, + ProofCommitment: []byte{0xdd}, + ProofHeight: height40, + }) + require.NoError(t, err) + assert.Equal(t, wantRecv4, calls[2]) + + packet2, err := toContractPacket(relayPacket(2)) + require.NoError(t, err) + height20 := proofHeight(20) + wantAck, err := routerABI.Pack(ackPacketMethod, ics26router.IICS26RouterMsgsMsgAckPacket{ + Packet: packet2, + Acknowledgement: []byte{0x99}, + ProofAcked: []byte{0xbb}, + ProofHeight: height20, + }) + require.NoError(t, err) + assert.Equal(t, wantAck, calls[3]) + + packet3, err := toContractPacket(relayPacket(3)) + require.NoError(t, err) + height30 := proofHeight(30) + wantTimeout, err := routerABI.Pack(timeoutPacketMethod, ics26router.IICS26RouterMsgsMsgTimeoutPacket{ + Packet: packet3, + ProofTimeout: []byte{0xcc}, + ProofHeight: height30, + }) + require.NoError(t, err) + assert.Equal(t, wantTimeout, calls[4]) + + // updateClient is first and round-trips. + wantUpdate, err := routerABI.Pack(updateClientMethod, update.ClientID, update.StateProof) + require.NoError(t, err) + assert.Equal(t, wantUpdate, calls[0]) +} + +func TestBuildRelayTxErrors(t *testing.T) { + builder, err := NewTxBuilder(routerAddress) + require.NoError(t, err) + + t.Run("nilUpdate", func(t *testing.T) { + _, _, err := builder.BuildRelayTx(nil, nil) + require.ErrorContains(t, err, "client update is required") + }) + + t.Run("multiPayload", func(t *testing.T) { + packet := relayPacket(1) + packet.Payloads = append(packet.Payloads, packet.Payloads[0]) + _, _, err := builder.BuildRelayTx(&chains.ClientUpdate{ClientID: "c"}, []chains.PacketRelayItem{ + {Kind: chains.RelayRecv, Packet: packet, ProofHeight: 1}, + }) + require.ErrorContains(t, err, "single-payload") + }) + + t.Run("ackWithoutAck", func(t *testing.T) { + _, _, err := builder.BuildRelayTx(&chains.ClientUpdate{ClientID: "c"}, []chains.PacketRelayItem{ + {Kind: chains.RelayAck, Packet: relayPacket(1), ProofHeight: 1}, + }) + require.ErrorContains(t, err, "acknowledgement") + }) +} diff --git a/link/internal/chains/manager/manager.go b/link/internal/chains/manager/manager.go new file mode 100644 index 000000000..698c0ad43 --- /dev/null +++ b/link/internal/chains/manager/manager.go @@ -0,0 +1,89 @@ +// Package manager provides chain client lookup. +package manager + +import ( + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/config" +) + +// ClientManager holds the chain clients for all configured chains. +type ClientManager struct { + clients map[string]chains.Client +} + +func New(clients map[string]chains.Client) *ClientManager { + if clients == nil { + clients = make(map[string]chains.Client) + } + + return &ClientManager{clients: clients} +} + +func NewFromConfig(cfg config.Config) (*ClientManager, error) { + clients := make(map[string]chains.Client) + + for _, chain := range cfg.Chains { + if chain.Type() != config.ChainTypeEVM { + _ = closeClients(clients) + return nil, errors.Errorf("unsupported chain type for chain %q", chain.ChainID) + } + + // ics26Router is optional in the config schema (pre-deploy configs omit it), + // but the daemon cannot run without it. Enforce a clear error at startup. + if chain.EVM.ICS26Router == "" { + _ = closeClients(clients) + return nil, errors.Errorf( + "chain %q has no evm.ics26Router configured; run `ibc connect`/`ibc deploy` first "+ + "or set chains[].evm.ics26Router", chain.ChainID) + } + + client, err := evm.New(chain.ChainID, chain.EVM.RPC, chain.EVM.ICS26Router) + if err != nil { + // Unwind already-constructed clients so a partial failure leaks no + // open RPC connections. + _ = closeClients(clients) + return nil, errors.Wrapf(err, "creating evm client for chain %q", chain.ChainID) + } + + clients[chain.ChainID] = client + } + + return New(clients), nil +} + +func (m *ClientManager) GetClient(chainID string) (chains.Client, error) { + client, ok := m.clients[chainID] + if !ok { + return nil, errors.Errorf("no configured chain client for chain ID %s", chainID) + } + + return client, nil +} + +// Close releases every chain client that owns a closable transport. +func (m *ClientManager) Close() error { + return closeClients(m.clients) +} + +// closeClients closes all clients that expose Close, joining any errors. +func closeClients(clients map[string]chains.Client) error { + var errs []error + for id, client := range clients { + closer, ok := client.(interface{ Close() error }) + if !ok { + continue + } + if err := closer.Close(); err != nil { + errs = append(errs, errors.Wrapf(err, "closing chain %q", id)) + } + } + + if len(errs) == 0 { + return nil + } + + return errors.Errorf("closing chain clients: %v", errs) +} diff --git a/link/internal/chains/relay.go b/link/internal/chains/relay.go new file mode 100644 index 000000000..55234bb52 --- /dev/null +++ b/link/internal/chains/relay.go @@ -0,0 +1,38 @@ +package chains + +// RelayKind the kind of packet relay operation. +type RelayKind int + +// Relay kinds +const ( + RelayRecv RelayKind = iota + RelayAck + RelayTimeout +) + +// ProofKind the kind of packet proof a ProofGenerator produces. +type ProofKind int + +// Proof kinds +const ( + KindPacketCommitment ProofKind = iota + KindAcknowledgement + KindReceiptAbsence +) + +// PacketRelayItem a single packet relay operation with its membership/absence proof. +type PacketRelayItem struct { + Kind RelayKind + Packet Packet + Acks [][]byte // populated only for Kind == RelayAck, one per payload + Proof []byte + ProofHeight uint64 +} + +// ClientUpdate a light client update to apply on the target chain before packet relay. +type ClientUpdate struct { + // ClientID the client updated on the tx target chain. + ClientID string + // StateProof the state update proof (attestation blob for the attestation client). + StateProof []byte +} diff --git a/link/internal/clientkey/clientkey.go b/link/internal/clientkey/clientkey.go new file mode 100644 index 000000000..69cfce28e --- /dev/null +++ b/link/internal/clientkey/clientkey.go @@ -0,0 +1,31 @@ +// Package clientkey defines the single canonical encoding of a +// (chainID, clientID) pair as "/". +// +// This format is BOTH: +// - the persisted relay cursor key (store relay_cursors), and +// - the wire route-id fallback form exposed by the relayer HTTP API. +// +// It is therefore a stable on-disk and on-the-wire contract: changing the +// delimiter or layout orphans every persisted relay cursor and breaks existing +// route-id callers. Do not fork this format; use Format/Parse everywhere. +package clientkey + +import "strings" + +// Format encodes a (chainID, clientID) pair as "/". +func Format(chainID, clientID string) string { + return chainID + "/" + clientID +} + +// Parse splits a "/" key back into its parts. It uses the +// last "/" as the delimiter so chain ids that themselves contain slashes are +// handled gracefully. ok is false when the key has no interior delimiter (no +// slash, or a leading/trailing slash that would yield an empty part). +func Parse(key string) (chainID, clientID string, ok bool) { + sep := strings.LastIndex(key, "/") + if sep <= 0 || sep >= len(key)-1 { + return "", "", false + } + + return key[:sep], key[sep+1:], true +} diff --git a/link/internal/clientkey/clientkey_test.go b/link/internal/clientkey/clientkey_test.go new file mode 100644 index 000000000..c8f58339a --- /dev/null +++ b/link/internal/clientkey/clientkey_test.go @@ -0,0 +1,60 @@ +package clientkey + +import "testing" + +func TestFormatParseRoundTrip(t *testing.T) { + cases := []struct { + name string + chainID string + clientID string + }{ + {"simple", "chain-a", "07-tendermint-0"}, + {"numeric chain", "1", "client-9"}, + {"slash in chain id", "eip155/1", "07-tendermint-0"}, + {"multiple slashes in chain id", "cosmos/hub/4", "client-0"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key := Format(tc.chainID, tc.clientID) + + chainID, clientID, ok := Parse(key) + if !ok { + t.Fatalf("Parse(%q) ok=false, want true", key) + } + if chainID != tc.chainID { + t.Errorf("chainID = %q, want %q", chainID, tc.chainID) + } + if clientID != tc.clientID { + t.Errorf("clientID = %q, want %q", clientID, tc.clientID) + } + }) + } +} + +func TestFormat(t *testing.T) { + if got := Format("chain-a", "client-0"); got != "chain-a/client-0" { + t.Errorf("Format = %q, want %q", got, "chain-a/client-0") + } +} + +func TestParseInvalid(t *testing.T) { + cases := []struct { + name string + key string + }{ + {"empty", ""}, + {"no delimiter", "chainonly"}, + {"leading slash only", "/client-0"}, + {"trailing slash only", "chain-a/"}, + {"just a slash", "/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, _, ok := Parse(tc.key); ok { + t.Errorf("Parse(%q) ok=true, want false", tc.key) + } + }) + } +} diff --git a/link/internal/config/config.go b/link/internal/config/config.go index 7c161c201..de493a0c2 100644 --- a/link/internal/config/config.go +++ b/link/internal/config/config.go @@ -10,8 +10,6 @@ import ( "github.com/goccy/go-yaml" "github.com/pkg/errors" - - "github.com/cosmos/ibc/link/internal/network" ) // Database type @@ -86,9 +84,20 @@ type AttestationConfig struct { // Should be unique across all attestations! Signer string `yaml:"signer"` - // todo: future work - RouterAddress string `yaml:"-"` - FinalityOffset int64 `yaml:"-"` + // EVM holds the EVM-specific attestor settings. Required (EVM is the only + // supported attestation environment today). + EVM *AttestationEVMConfig `yaml:"evm,omitempty"` +} + +// AttestationEVMConfig holds EVM-specific attestor settings. +type AttestationEVMConfig struct { + // RouterAddress is the ICS26 router contract address (hex, 0x-prefixed) queried + // for packet commitments. + RouterAddress string `yaml:"routerAddress"` + + // FinalityOffset attests to heights <= latestHeight - finalityOffset. When unset, + // the chain's native finality ("finalized" tag) is used. + FinalityOffset *uint64 `yaml:"finalityOffset,omitempty"` } // ChainType the execution environment of a chain. @@ -141,9 +150,9 @@ func DefaultConfig() Config { } } -// LoadFromFile loads Config from file with optional validation. +// LoadFromFile loads Config from file. // Note: supports ENV variables expansion! -func LoadFromFile(path string, validate, restrictUnknownFields bool) (Config, error) { +func LoadFromFile(path string, restrictUnknownFields bool) (Config, error) { config := DefaultConfig() bz, err := os.ReadFile(path) @@ -164,91 +173,9 @@ func LoadFromFile(path string, validate, restrictUnknownFields bool) (Config, er return Config{}, err } - if validate { - if err := config.Validate(); err != nil { - return Config{}, errors.Wrap(err, "validation failed") - } - } - return config, nil } -func (c Config) Validate() error { - if err := c.Server.Validate(); err != nil { - return errors.Wrap(err, ".server") - } - - if err := c.DB.Validate(); err != nil { - return errors.Wrap(err, ".db") - } - - chainIDs := make(map[string]struct{}) - for _, chain := range c.Chains { - if err := chain.Validate(); err != nil { - return errors.Wrapf(err, ".chains[%s]", chain.ChainID) - } - - if _, ok := chainIDs[chain.ChainID]; ok { - return errors.Wrapf(errors.Errorf("duplicate chainId: %q", chain.ChainID), ".chains") - } - chainIDs[chain.ChainID] = struct{}{} - } - - if err := c.Relayer.Validate(); err != nil { - return errors.Wrap(err, ".relayer") - } - - if err := c.Attestor.Validate(); err != nil { - return errors.Wrap(err, ".attestor") - } - - if err := c.Signers.Validate(); err != nil { - return errors.Wrap(err, ".signers") - } - - return c.crossValidate() -} - -func (c Config) crossValidate() error { - signerSet := make(map[string]struct{}, len(c.Signers)) - for _, signer := range c.Signers { - signerSet[signer.Alias] = struct{}{} - } - - for i, attestation := range c.Attestor.Attestations { - if _, exists := signerSet[attestation.Signer]; !exists { - return errors.Errorf( - ".attestor.attestations[%d].signer references unknown signer: %q", - i, - attestation.Signer, - ) - } - } - - return c.validateChainReferences() -} - -// validateChainReferences ensures chains referenced by the relayer config are -// declared in the top-level chains block. -func (c Config) validateChainReferences() error { - for _, chain := range c.Relayer.ChainOverrides { - if _, ok := c.Chain(chain.ChainID); chain.ChainID != "" && !ok { - return errors.Errorf(".chainOverrides[%s] chainId not declared in top-level chains", chain.ChainID) - } - } - - for _, client := range c.Relayer.Clients { - if _, ok := c.Chain(client.ChainID); client.ChainID != "" && !ok { - return errors.Errorf( - ".clients[%s] chainId %q not declared in top-level chains", - client.ClientID, client.ChainID, - ) - } - } - - return nil -} - func (c Config) Chain(chainID string) (ChainConfig, bool) { for _, chain := range c.Chains { if chain.ChainID == chainID { @@ -259,23 +186,6 @@ func (c Config) Chain(chainID string) (ChainConfig, bool) { return ChainConfig{}, false } -func (c ChainConfig) Validate() error { - if c.ChainID == "" { - return errors.New(".chainId required") - } - - if c.Type() == ChainTypeEVM { - switch { - case c.EVM.RPC == "": - return errors.New(".evm.rpc required") - case c.EVM.ICS26Router == "": - return errors.New(".evm.ics26Router required") - } - } - - return nil -} - func (c Config) StoreToFile(path string) error { if err := EnsureDirectory(path); err != nil { return err @@ -293,27 +203,6 @@ func (c Config) StoreToFile(path string) error { return nil } -func (c ServerConfig) Validate() error { - if err := network.ValidateListenAddr(c.ListenAddress); err != nil { - return errors.Wrapf(err, ".listenAddr %q", c.ListenAddress) - } - - return nil -} - -func (c DBConfig) Validate() error { - switch { - case c.Type != DBTypeSQLite && c.Type != DBTypePostgres: - return errors.Errorf(".type must be one of [%q, %q], got %q", DBTypeSQLite, DBTypePostgres, c.Type) - case c.Type == DBTypeSQLite && c.URL == sqliteInMemory: - return errors.New(".url must not be :memory: for sqlite") - case c.URL == "": - return errors.New(".url must not be empty") - } - - return nil -} - // Label returns a human-readable label for the DB config. func (c DBConfig) Label() string { if c.Type != DBTypeSQLite { @@ -330,126 +219,11 @@ func (c DBConfig) Label() string { } // DBConfigFromURL infers DB type from a CLI database URL override. -func DBConfigFromURL(url string) (DBConfig, error) { - db := DBConfig{ +func DBConfigFromURL(url string) DBConfig { + return DBConfig{ URL: url, Type: dbTypeFromURL(url), } - - return db, db.Validate() -} - -// Validate validates the attestor config. Allows empty attestations. -func (c AttestorConfig) Validate() error { - nameSet := make(map[string]struct{}) - signerSet := make(map[string]struct{}) - - for i, attestation := range c.Attestations { - if err := attestation.Validate(); err != nil { - return errors.Wrapf(err, ".attestations[%d]", i) - } - - if _, exists := nameSet[attestation.Name]; exists { - return errors.Errorf(".attestations[%d] duplicate name: %q", i, attestation.Name) - } - - if _, exists := signerSet[attestation.Signer]; exists { - return errors.Errorf(".attestations[%d] duplicate signer: %q", i, attestation.Signer) - } - - nameSet[attestation.Name] = struct{}{} - signerSet[attestation.Signer] = struct{}{} - } - - return nil -} - -func (c AttestationConfig) Validate() error { - switch { - case c.ChainID == "": - return errors.Errorf(".chainId required") - case c.Name == "": - return errors.Errorf(".name required") - case c.Signer == "": - return errors.Errorf(".signer required") - } - - return nil -} - -func (c Signers) Validate() error { - set := make(map[string]struct{}) - - for i, signer := range c { - if err := signer.Validate(); err != nil { - return errors.Wrapf(err, ".signers[%d]", i) - } - - if _, exists := set[signer.Alias]; exists { - return errors.Errorf(".signers duplicate alias: %q", signer.Alias) - } - - set[signer.Alias] = struct{}{} - } - - return nil -} - -func (c SignerConfig) Validate() error { - switch { - case c.Alias == "": - return errors.New(".alias required") - case c.Type == "": - return errors.New(".type required") - case c.Type != SignerLocal && c.Type != SignerRemote: - return errors.Errorf(".type must be one of [%q, %q], got %q", SignerLocal, SignerRemote, c.Type) - case c.Type == SignerLocal && c.File == "": - return errors.New(".file required for local signer") - case c.Type == SignerRemote && c.GRPC == "": - return errors.New(".grpc required for remote signer") - case c.Type == SignerRemote && c.RemoteKeyID == "": - return errors.New(".remoteKeyId required for remote signer") - } - - if c.Type == SignerLocal { - path, err := ExpandHome(c.File) - if err != nil { - return errors.Wrap(err, ".file") - } - - fallbacks := KeyFileFallbacks(path) - - if err := fileExistsInAny(fallbacks...); err != nil { - return errors.Wrapf(err, ".file %s", path) - } - } - - return nil -} - -func KeyFileFallbacks(keyPath string) []string { - fallbacks := []string{keyPath} - - // absolute path, no fallbacks needed - if filepath.IsAbs(keyPath) { - return fallbacks - } - - // forgot to add .json extension - if !strings.HasSuffix(keyPath, ".json") { - keyPath = fmt.Sprintf("%s.json", keyPath) - - fallbacks = append(fallbacks, keyPath) - } - - // forgot to add keys/ directory - if !strings.Contains(keyPath, "keys/") { - keyPath = filepath.Join("keys", keyPath) - - fallbacks = append(fallbacks, keyPath) - } - - return fallbacks } // PrintJSON prints anything as JSON to stdout. @@ -472,16 +246,6 @@ func dbTypeFromURL(raw string) string { return DBTypeSQLite } -func fileExistsInAny(path ...string) error { - for _, p := range path { - if err := fileExists(p); err == nil { - return nil - } - } - - return errors.New("file not found") -} - func fileExists(path string) error { info, err := os.Stat(path) if err != nil { diff --git a/link/internal/config/config_test.go b/link/internal/config/config_test.go index 3b3cc6cd1..9032b3ec5 100644 --- a/link/internal/config/config_test.go +++ b/link/internal/config/config_test.go @@ -34,14 +34,14 @@ func TestConfig(t *testing.T) { patch: func(c *Config) { c.DB.Type = "mysql" }, - errContains: ".type must be one of", + errContains: "db.type: must be one of", }, { name: "empty db url", patch: func(c *Config) { c.DB.URL = "" }, - errContains: ".url must not be empty", + errContains: "db.url: must not be empty", }, } { t.Run(tt.name, func(t *testing.T) { @@ -50,7 +50,7 @@ func TestConfig(t *testing.T) { tt.patch(&config) } - err := config.Validate() + err := config.Validate().Err() if tt.errContains != "" { require.ErrorContains(t, err, tt.errContains) return @@ -73,7 +73,7 @@ db: `) // ACT - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT require.NoError(t, err) @@ -91,7 +91,7 @@ server: `) // ACT - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT require.NoError(t, err) @@ -106,13 +106,13 @@ server: `) // ACT - _, err := LoadFromFile(path, true, true) + _, err := LoadFromFile(path, true) // ASSERT require.Error(t, err) }) - t.Run("validationFails", func(t *testing.T) { + t.Run("loadingDoesNotValidate", func(t *testing.T) { // ARRANGE path := writeTestConfig(t, ` server: @@ -120,11 +120,11 @@ server: `) // ACT - _, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT - require.ErrorContains(t, err, "validation failed") - require.ErrorContains(t, err, "expected address in host:port") + require.NoError(t, err) + require.ErrorContains(t, config.Validate().Err(), "expected address in host:port") }) t.Run("fileNotFound", func(t *testing.T) { @@ -132,7 +132,7 @@ server: path := filepath.Join(t.TempDir(), "missing.yml") // ACT - _, err := LoadFromFile(path, true, true) + _, err := LoadFromFile(path, true) // ASSERT require.Error(t, err) @@ -171,7 +171,7 @@ server: path := writeTestConfig(t, tt.body) // ACT - _, err := LoadFromFile(path, true, true) + _, err := LoadFromFile(path, true) // ASSERT require.ErrorContains(t, err, "unknown field") @@ -187,7 +187,7 @@ server: `) // ACT - config, err := LoadFromFile(path, true, false) + config, err := LoadFromFile(path, false) // ASSERT require.NoError(t, err) @@ -197,6 +197,11 @@ server: t.Run("attestationSigner", func(t *testing.T) { // ARRANGE path := writeTestConfig(t, ` +chains: + - chainId: chain-a + evm: + rpc: https://rpc.example.com + ics26Router: "0x0000000000000000000000000000000000000000" signers: - alias: signer-a type: remote @@ -207,20 +212,31 @@ attestor: - chainId: chain-a name: attestation-a signer: signer-a + evm: + routerAddress: "0x0000000000000000000000000000000000000000" + finalityOffset: 1 `) // ACT - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT require.NoError(t, err) require.Len(t, config.Attestor.Attestations, 1) assert.Equal(t, "signer-a", config.Attestor.Attestations[0].Signer) + require.NotNil(t, config.Attestor.Attestations[0].EVM) + require.NotNil(t, config.Attestor.Attestations[0].EVM.FinalityOffset) + assert.Equal(t, uint64(1), *config.Attestor.Attestations[0].EVM.FinalityOffset) }) t.Run("unknownAttestationSignerFails", func(t *testing.T) { // ARRANGE path := writeTestConfig(t, ` +chains: + - chainId: chain-a + evm: + rpc: https://rpc.example.com + ics26Router: "0x0000000000000000000000000000000000000000" signers: - alias: signer-a type: remote @@ -231,22 +247,49 @@ attestor: - chainId: chain-a name: attestation-a signer: missing-signer + evm: + routerAddress: "0x0000000000000000000000000000000000000000" `) // ACT - _, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT - require.ErrorContains(t, err, `references unknown signer: "missing-signer"`) + require.NoError(t, err) + require.ErrorContains(t, config.Validate().Err(), `references unknown signer: "missing-signer"`) + }) + + t.Run("unknownAttestationChainFails", func(t *testing.T) { + // ARRANGE + path := writeTestConfig(t, ` +signers: + - alias: signer-a + type: remote + grpc: https://kms.example.com + remoteKeyId: key-a +attestor: + attestations: + - chainId: missing-chain + name: attestation-a + signer: signer-a + evm: + routerAddress: "0x0000000000000000000000000000000000000000" +`) + + // ACT + config, err := LoadFromFile(path, true) + + // ASSERT + require.NoError(t, err) + require.ErrorContains(t, config.Validate().Err(), `chainId "missing-chain" not declared in top-level chains`) }) }) t.Run("DBConfigFromURL", func(t *testing.T) { for _, tt := range []struct { - name string - raw string - wantType string - errContains string + name string + raw string + wantType string }{ { name: "default relative sqlite path", @@ -288,251 +331,168 @@ attestor: raw: "postgresql://user:pass@localhost:5432/ibc", wantType: DBTypePostgres, }, - { - name: "empty", - raw: "", - errContains: ".url must not be empty", - }, + {name: "empty", raw: "", wantType: DBTypeSQLite}, } { t.Run(tt.name, func(t *testing.T) { // ACT - db, err := DBConfigFromURL(tt.raw) + db := DBConfigFromURL(tt.raw) // ASSERT - if tt.errContains != "" { - require.ErrorContains(t, err, tt.errContains) - return - } - - require.NoError(t, err) assert.Equal(t, tt.wantType, db.Type) + assert.Equal(t, tt.raw, db.URL) }) } }) } -func TestAttestorConfigValidate(t *testing.T) { - for _, tt := range []struct { - name string - attestor AttestorConfig - errContains string - }{ - { - name: "empty attestations", - attestor: AttestorConfig{}, - }, - { - name: "valid attestations", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{ - { - ChainID: "chain-a", - Name: "attestation-a", - Signer: "signer-a", - }, - { - ChainID: "chain-b", - Name: "attestation-b", - Signer: "signer-b", - }, - }, - }, - }, - { - name: "chain id required", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{{ - Name: "attestation-a", - Signer: "signer-a", - }}, +func TestValidateAttestor(t *testing.T) { + validEVM := func() *AttestationEVMConfig { + return &AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000000"} + } + withAttestations := func(atts ...AttestationConfig) Config { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Attestor = AttestorConfig{Attestations: atts} + + return cfg + } + + t.Run("fieldErrors", func(t *testing.T) { + for _, tt := range []struct { + name string + att AttestationConfig + path, msg string + }{ + { + "chainId required", + AttestationConfig{Name: "a", Signer: "s", EVM: validEVM()}, + "attestor.attestations[0].chainId", "required", }, - errContains: ".attestations[0]: .chainId required", - }, - { - name: "name required", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{{ - ChainID: "chain-a", - Signer: "signer-a", - }}, + { + "name required", + AttestationConfig{ChainID: "chain-a", Signer: "s", EVM: validEVM()}, + "attestor.attestations[0].name", "required", }, - errContains: ".attestations[0]: .name required", - }, - { - name: "signer required", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{{ - ChainID: "chain-a", - Name: "attestation-a", - }}, + { + "signer required", + AttestationConfig{ChainID: "chain-a", Name: "a", EVM: validEVM()}, + "attestor.attestations[0].signer", "required", }, - errContains: ".attestations[0]: .signer required", - }, - { - name: "duplicate name", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{ - { - ChainID: "chain-a", - Name: "same", - Signer: "signer-a", - }, - { - ChainID: "chain-b", - Name: "same", - Signer: "signer-b", - }, - }, + { + "evm required", + AttestationConfig{ChainID: "chain-a", Name: "a", Signer: "s"}, + "attestor.attestations[0].evm", "required", }, - errContains: `.attestations[1] duplicate name: "same"`, - }, - { - name: "duplicate signer", - attestor: AttestorConfig{ - Attestations: []AttestationConfig{ - { - ChainID: "chain-a", - Name: "attestation-a", - Signer: "same", - }, - { - ChainID: "chain-b", - Name: "attestation-b", - Signer: "same", - }, - }, + { + "routerAddress invalid hex", + AttestationConfig{ChainID: "chain-a", Name: "a", Signer: "s", EVM: &AttestationEVMConfig{RouterAddress: "not-an-address"}}, + "attestor.attestations[0].evm.routerAddress", "must be a valid hex address", }, - errContains: `.attestations[1] duplicate signer: "same"`, - }, - } { - t.Run(tt.name, func(t *testing.T) { - // ACT - err := tt.attestor.Validate() + } { + t.Run(tt.name, func(t *testing.T) { + errs := withAttestations(tt.att).Validate().Errors + assert.True(t, hasError(errs, tt.path, tt.msg), "%+v", errs) + }) + } + }) - // ASSERT - if tt.errContains != "" { - require.ErrorContains(t, err, tt.errContains) - return - } + t.Run("routerAddressOptional", func(t *testing.T) { + // routerAddress is optional at config time; an empty router must NOT error. + errs := withAttestations( + AttestationConfig{ChainID: "chain-a", Name: "a", Signer: "s", EVM: &AttestationEVMConfig{}}, + ).Validate().Errors + assert.False(t, hasError(errs, "attestor.attestations[0].evm.routerAddress", ""), "%+v", errs) + }) - require.NoError(t, err) - }) - } + t.Run("duplicateName", func(t *testing.T) { + errs := withAttestations( + AttestationConfig{ChainID: "chain-a", Name: "same", Signer: "s1", EVM: validEVM()}, + AttestationConfig{ChainID: "chain-b", Name: "same", Signer: "s2", EVM: validEVM()}, + ).Validate().Errors + assert.True(t, hasError(errs, "attestor.attestations[1].name", "duplicate name"), "%+v", errs) + }) + + t.Run("duplicateSigner", func(t *testing.T) { + errs := withAttestations( + AttestationConfig{ChainID: "chain-a", Name: "a1", Signer: "same", EVM: validEVM()}, + AttestationConfig{ChainID: "chain-b", Name: "a2", Signer: "same", EVM: validEVM()}, + ).Validate().Errors + assert.True(t, hasError(errs, "attestor.attestations[1].signer", "duplicate signer"), "%+v", errs) + }) } -func TestSignerConfigValidate(t *testing.T) { - keyFile := filepath.Join(t.TempDir(), "key.json") - require.NoError(t, os.WriteFile(keyFile, []byte("{}"), 0o644)) - - for _, tt := range []struct { - name string - signers Signers - errContains string - }{ - { - name: "valid local", - signers: Signers{{ - Alias: "local", - Type: SignerLocal, - File: keyFile, - }}, - }, - { - name: "valid remote", - signers: Signers{{ - Alias: "remote", - Type: SignerRemote, - GRPC: "https://kms.example.com", - RemoteKeyID: "key-1", - }}, - }, - { - name: "alias required", - signers: Signers{{ - Type: SignerLocal, - File: keyFile, - }}, - errContains: ".alias required", - }, - { - name: "type required", - signers: Signers{{ - Alias: "local", - File: keyFile, - }}, - errContains: ".type required", - }, - { - name: "invalid type", - signers: Signers{{ - Alias: "local", - Type: "kms", - }}, - errContains: ".type must be one of", - }, - { - name: "local file required", - signers: Signers{{ - Alias: "local", - Type: SignerLocal, - }}, - errContains: ".file required", - }, - { - name: "local file must exist", - signers: Signers{{ - Alias: "local", - Type: SignerLocal, - File: filepath.Join(t.TempDir(), "missing.json"), - }}, - errContains: ".file", - }, - { - name: "remote grpc required", - signers: Signers{{ - Alias: "remote", - Type: SignerRemote, - RemoteKeyID: "key-1", - }}, - errContains: ".grpc required", - }, - { - name: "remote key id required", - signers: Signers{{ - Alias: "remote", - Type: SignerRemote, - GRPC: "https://kms.example.com", - }}, - errContains: ".remoteKeyId required", - }, - { - name: "duplicate alias", - signers: Signers{ - { - Alias: "same", - Type: SignerLocal, - File: keyFile, - }, - { - Alias: "same", - Type: SignerRemote, - GRPC: "https://kms.example.com", - RemoteKeyID: "key-1", - }, - }, - errContains: ".signers duplicate alias", - }, - } { - t.Run(tt.name, func(t *testing.T) { - err := tt.signers.Validate() - if tt.errContains != "" { - require.ErrorContains(t, err, tt.errContains) - return - } +func TestValidateSigner(t *testing.T) { + withSigners := func(signers ...SignerConfig) Config { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Signers = signers - require.NoError(t, err) - }) + return cfg } + + t.Run("fieldErrors", func(t *testing.T) { + for _, tt := range []struct { + name string + signer SignerConfig + path, msg string + }{ + { + "alias required", + SignerConfig{Type: SignerRemote, GRPC: "grpc://x", RemoteKeyID: "k"}, + "signers[0].alias", "required", + }, + {"type required", SignerConfig{Alias: "a"}, "signers[0].type", "required"}, + {"invalid type", SignerConfig{Alias: "a", Type: "kms"}, "signers[0].type", "must be one of"}, + {"local file required", SignerConfig{Alias: "a", Type: SignerLocal}, "signers[0].file", "required"}, + { + "remote grpc required", + SignerConfig{Alias: "a", Type: SignerRemote, RemoteKeyID: "k"}, + "signers[0].grpc", "required", + }, + { + "remote keyId required", + SignerConfig{Alias: "a", Type: SignerRemote, GRPC: "grpc://x"}, + "signers[0].remoteKeyId", "required", + }, + } { + t.Run(tt.name, func(t *testing.T) { + errs := withSigners(tt.signer).Validate().Errors + assert.True(t, hasError(errs, tt.path, tt.msg), "%+v", errs) + }) + } + }) + + t.Run("duplicateAlias", func(t *testing.T) { + keyFile := filepath.Join(t.TempDir(), "key.json") + require.NoError(t, os.WriteFile(keyFile, []byte("{}"), 0o600)) + + errs := withSigners( + SignerConfig{Alias: "same", Type: SignerLocal, File: keyFile}, + SignerConfig{Alias: "same", Type: SignerRemote, GRPC: "grpc://x", RemoteKeyID: "k"}, + ).Validate().Errors + assert.True(t, hasError(errs, "signers[1].alias", "duplicate alias"), "%+v", errs) + }) + + t.Run("localFileMissingIsWarningNotError", func(t *testing.T) { + // The key file's presence is machine state, not config correctness, so a + // missing file warns instead of failing validation. + validation := withSigners( + SignerConfig{Alias: "a", Type: SignerLocal, File: filepath.Join(t.TempDir(), "missing.json")}, + ).Validate() + assert.False(t, hasError(validation.Errors, "signers[0].file", ""), "%+v", validation.Errors) + assert.True(t, hasWarning(validation.Warnings, "signers[0].file", "missing.json"), "%+v", validation.Warnings) + }) + + t.Run("localFileMustBeExactPath", func(t *testing.T) { + // A local signer must reference the exact key file, not a prefix of it. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "signer.json"), []byte("{}"), 0o600)) + + validation := withSigners( + SignerConfig{Alias: "a", Type: SignerLocal, File: filepath.Join(dir, "signer")}, + ).Validate() + assert.True(t, hasWarning(validation.Warnings, "signers[0].file", ""), "%+v", validation.Warnings) + }) } func writeTestConfig(t *testing.T, content string) string { diff --git a/link/internal/config/ibc.yml b/link/internal/config/ibc.yml index a240d2db7..464cd953e 100644 --- a/link/internal/config/ibc.yml +++ b/link/internal/config/ibc.yml @@ -22,6 +22,10 @@ chains: ics26Router: "0x0000000000000000000000000000000000000000" relayer: + # signer references a top-level signers[] alias; used to sign relay txs on all + # chains. Required when clients is non-empty. It MUST be a local signer: EVM tx + # signing needs the raw secp256k1 key, so remote KMS relayer signing is rejected. + signer: "my-relayer" # relay specific chain settings chainOverrides: - chainId: "1" @@ -43,6 +47,9 @@ relayer: - name: "attestor-base" type: remote grpc: attestor.example.com:3000 + # on-chain signer identity (required for remote attestors; the relayer + # cannot derive it from a key file). Obtain via `ibc keys show `. + evmAddress: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" - alias: "base-to-eth" clientId: "ethereum-0" chainId: "8453" @@ -56,6 +63,7 @@ relayer: - name: "attestor-ethereum" type: remote grpc: attestor.example.com:3000 + evmAddress: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" # packets sent from the source client are relayed through the entire packet # lifecycle: recv, ack, timeout @@ -63,14 +71,18 @@ relayer: - sourceClient: "eth-to-base" - sourceClient: "base-to-eth" -# signers define signing backends referenced by attestations +# signers define signing backends referenced by the relayer and attestations # # NOTE: each signer should have a unique alias signers: -# - alias: my-local-signer -# type: local -# file: keys/my-key.json + # The relayer signer is local (see relayer.signer above). Generate the key first: + # ibc keys new ecdsa my-relayer + # which writes it to /keys/my-relayer.json (the path referenced here). + - alias: my-relayer + type: local + file: keys/my-relayer.json # +# # Remote (KMS) signers are supported for ATTESTATIONS (see the attestor block). # - alias: my-remote-signer # type: remote # grpc: https://cosmos-kms.example.com @@ -83,9 +95,14 @@ signers: # NOTE: each attestation should have a unique name and reference a unique signer attestor: # attestations: -# - chainId: chain-a +# - chainId: "1" # name: attestation-a # signer: my-local-signer -# - chainId: chain-b +# evm: +# routerAddress: "0x0000000000000000000000000000000000000000" +# finalityOffset: 1 # attest to heights <= latest - offset; native finality if unset +# - chainId: "8453" # name: attestation-b # signer: my-remote-signer +# evm: +# routerAddress: "0x0000000000000000000000000000000000000000" diff --git a/link/internal/config/relayer.go b/link/internal/config/relayer.go index a3406f7aa..130474ca8 100644 --- a/link/internal/config/relayer.go +++ b/link/internal/config/relayer.go @@ -1,9 +1,13 @@ package config import ( + "fmt" + "math/big" "time" - "github.com/pkg/errors" + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/link/internal/clientkey" ) // ClientType the light client type. @@ -25,18 +29,97 @@ const ( // RelayerConfig the relayer block of the config. type RelayerConfig struct { - ChainOverrides []RelayerChainOverride `yaml:"chainOverrides"` - Clients []ClientConfig `yaml:"clients"` - Routes []RouteConfig `yaml:"routesToRelay"` + // Signer the alias of the signer (from the top-level signers block) used to + // sign relay transactions on all chains. Required when Clients is non-empty. + Signer string `yaml:"signer"` + // GasAlertThresholds is the relayer-level default low-gas alert configuration + // applied to every relayed chain that does not set its own override. Optional: + // nil disables alerting by default (per-chain overrides can still enable it). + GasAlertThresholds *GasAlertThresholds `yaml:"gasAlertThresholds,omitempty"` + ChainOverrides []RelayerChainOverride `yaml:"chainOverrides"` + Clients []ClientConfig `yaml:"clients"` + Routes []RouteConfig `yaml:"routesToRelay"` +} + +// GasAlertThresholds configures the relayer signer wei balance levels at which a +// low-gas alert metric is surfaced for a chain. Both bounds are wei amounts given +// as base-10 strings, since gas balances routinely exceed the range of uint64. A +// balance at or below WarningThreshold raises a warning and at or below +// CriticalThreshold a critical alert, so CriticalThreshold must not exceed +// WarningThreshold. Absent config means no alert is evaluated (the balance gauge +// is still reported). +type GasAlertThresholds struct { + WarningThreshold string `yaml:"warningThreshold"` + CriticalThreshold string `yaml:"criticalThreshold"` +} + +// Parse returns the warning and critical thresholds as wei big.Ints. Config +// validation enforces the same invariants up front; Parse re-checks them so a +// programmatic caller (bootstrap) can never build a monitor from malformed +// thresholds. +func (t GasAlertThresholds) Parse() (warning, critical *big.Int, err error) { + warning, err = parseWei(t.WarningThreshold) + if err != nil { + return nil, nil, fmt.Errorf("warningThreshold: %w", err) + } + + critical, err = parseWei(t.CriticalThreshold) + if err != nil { + return nil, nil, fmt.Errorf("criticalThreshold: %w", err) + } + + if critical.Cmp(warning) > 0 { + return nil, nil, fmt.Errorf("criticalThreshold %s must not exceed warningThreshold %s", critical, warning) + } + + return warning, critical, nil +} + +// parseWei parses a nonnegative base-10 wei amount. +func parseWei(s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, fmt.Errorf("must be a base-10 integer, got %q", s) + } + if v.Sign() < 0 { + return nil, fmt.Errorf("must not be negative, got %q", s) + } + + return v, nil +} + +// GasAlertThresholdsFor returns the effective gas-alert thresholds for a chain: +// the chain override when set, otherwise the relayer-level default, otherwise nil +// (no alerting configured for the chain). +func (c RelayerConfig) GasAlertThresholdsFor(chainID string) *GasAlertThresholds { + for _, override := range c.ChainOverrides { + if override.ChainID == chainID && override.GasAlertThresholds != nil { + return override.GasAlertThresholds + } + } + + return c.GasAlertThresholds +} + +// PacketBatchSizeFor returns the configured packet batch size for a chain, +// falling back to def when no override sets it. +func (c RelayerConfig) PacketBatchSizeFor(chainID string, def int) int { + for _, override := range c.ChainOverrides { + if override.ChainID == chainID && override.PacketBatchSize != nil { + return *override.PacketBatchSize + } + } + + return def } // RelayerChainOverride relay settings for one chain. type RelayerChainOverride struct { - ChainID string `yaml:"chainId"` - EVM *RelayerEVMConfig `yaml:"evm,omitempty"` - TxSubmissionDelay *time.Duration `yaml:"txSubmissionDelay,omitempty"` - PacketBatchSize *int `yaml:"packetBatchSize,omitempty"` - PacketBatchTimeout *time.Duration `yaml:"packetBatchTimeout,omitempty"` + ChainID string `yaml:"chainId"` + EVM *RelayerEVMConfig `yaml:"evm,omitempty"` + TxSubmissionDelay *time.Duration `yaml:"txSubmissionDelay,omitempty"` + PacketBatchSize *int `yaml:"packetBatchSize,omitempty"` + GasAlertThresholds *GasAlertThresholds `yaml:"gasAlertThresholds,omitempty"` } // RelayerEVMConfig EVM relaying settings. @@ -69,6 +152,12 @@ type AttestorEntry struct { Name string `yaml:"name"` Type AttestorType `yaml:"type"` GRPC string `yaml:"grpc,omitempty"` + + // EVMAddress is the attestor's on-chain signer address (hex, 0x-prefixed), + // used as a light-client constructor argument by `ibc deploy`/`ibc connect`. + // Optional: for local attestors it is derived from the matching + // attestor.attestations[].signer key file when unset. + EVMAddress string `yaml:"evmAddress,omitempty"` } // RouteConfig packets sent from the source client are relayed through the @@ -86,6 +175,18 @@ type AutoRelayConfig struct { Lookback uint64 `yaml:"lookback,omitempty"` } +// HasRoute reports whether the client alias is the source of a configured relay +// route (routesToRelay). Only such clients are relayed, whether auto or manual. +func (c RelayerConfig) HasRoute(alias string) bool { + for _, route := range c.Routes { + if route.SourceClient == alias { + return true + } + } + + return false +} + // ClientByAlias returns the client config for the given alias. func (c RelayerConfig) ClientByAlias(alias string) (ClientConfig, bool) { for _, client := range c.Clients { @@ -107,210 +208,208 @@ func (c RelayerConfig) Client(chainID, clientID string) (ClientConfig, bool) { return ClientConfig{}, false } -// Validate validates the relayer config. Allows empty blocks. -func (c RelayerConfig) Validate() error { - if err := c.validateChainOverrides(); err != nil { - return err - } - - if err := c.validateClients(); err != nil { - return err - } - - return c.validateRoutes() +// validate collects all relayer validation errors with schema paths. +func (c RelayerConfig) validate(col *Validation) { + validateGasAlertThresholds(col, "relayer.gasAlertThresholds", c.GasAlertThresholds) + c.validateChainOverrides(col) + c.validateClients(col) + c.validateRoutes(col) } -func (c RelayerConfig) validateChainOverrides() error { - chainIDs := make(map[string]struct{}) - - for _, chain := range c.ChainOverrides { - if err := chain.Validate(); err != nil { - return errors.Wrapf(err, ".chainOverrides[%s]", chain.ChainID) - } - - if _, ok := chainIDs[chain.ChainID]; ok { - return errors.Errorf(".chainOverrides duplicate chainId: %q", chain.ChainID) - } - chainIDs[chain.ChainID] = struct{}{} - } - - return nil -} - -func (c RelayerConfig) validateClients() error { - clients := make(map[string]struct{}) - aliases := make(map[string]struct{}) - - for _, client := range c.Clients { - if err := client.Validate(); err != nil { - return errors.Wrapf(err, ".clients[%s]", client.Alias) - } - - if _, ok := aliases[client.Alias]; ok { - return errors.Errorf(".clients duplicate alias: %q", client.Alias) - } - aliases[client.Alias] = struct{}{} - - key := client.ChainID + "/" + client.ClientID - if _, ok := clients[key]; ok { - return errors.Errorf(".clients duplicate client %q on chain %q", client.ClientID, client.ChainID) - } - clients[key] = struct{}{} +// validateGasAlertThresholds checks that both bounds parse as +// nonnegative wei amounts and that critical does not exceed warning. A nil block +// is valid (alerting is optional). +func validateGasAlertThresholds(col *Validation, path string, t *GasAlertThresholds) { + if t == nil { + return } - for _, client := range c.Clients { - if err := c.validateCounterparty(client); err != nil { - return err - } + warning, werr := parseWei(t.WarningThreshold) + if werr != nil { + col.addf(path+".warningThreshold", "%v", werr) } - return nil -} - -// validateCounterparty ensures both sides of a connection are configured for bi-directional relaying. -func (c RelayerConfig) validateCounterparty(client ClientConfig) error { - counterparty, ok := c.Client(client.CounterpartyChainID, client.CounterpartyClientID) - if !ok { - return errors.Errorf( - ".clients[%s] counterparty client %q on chain %q must also be configured for bi-directional relaying", - client.Alias, client.CounterpartyClientID, client.CounterpartyChainID, - ) + critical, cerr := parseWei(t.CriticalThreshold) + if cerr != nil { + col.addf(path+".criticalThreshold", "%v", cerr) } - if counterparty.CounterpartyChainID != client.ChainID || counterparty.CounterpartyClientID != client.ClientID { - return errors.Errorf( - ".clients[%s] counterparty client %q does not reference it back", - client.Alias, counterparty.Alias, - ) + if werr == nil && cerr == nil && critical.Cmp(warning) > 0 { + col.add(path+".criticalThreshold", "must not exceed warningThreshold") } - - return nil } -func (c RelayerConfig) validateRoutes() error { - routes := make(map[string]struct{}) +func (c RelayerConfig) validateChainOverrides(col *Validation) { + seen := make(map[string]struct{}) - for i, route := range c.Routes { - if err := route.Validate(); err != nil { - return errors.Wrapf(err, ".routesToRelay[%d]", i) - } + for i, chain := range c.ChainOverrides { + path := fmt.Sprintf("relayer.chainOverrides[%d]", i) - if _, ok := c.ClientByAlias(route.SourceClient); !ok { - return errors.Errorf(".routesToRelay[%d] references unknown client %q", i, route.SourceClient) + switch { + case chain.ChainID == "": + col.add(path+".chainId", "required") + case chain.TxSubmissionDelay != nil && *chain.TxSubmissionDelay < 0: + col.add(path+".txSubmissionDelay", "must not be negative") + case chain.PacketBatchSize != nil && *chain.PacketBatchSize <= 0: + col.add(path+".packetBatchSize", "must be positive") } - if _, ok := routes[route.SourceClient]; ok { - return errors.Errorf(".routesToRelay duplicate route for client %q", route.SourceClient) + if chain.EVM != nil { + switch { + case chain.EVM.GasFeeCapMultiplier != nil && *chain.EVM.GasFeeCapMultiplier <= 0: + col.add(path+".evm.gasFeeCapMultiplier", "must be positive") + case chain.EVM.GasTipCapMultiplier != nil && *chain.EVM.GasTipCapMultiplier <= 0: + col.add(path+".evm.gasTipCapMultiplier", "must be positive") + } } - routes[route.SourceClient] = struct{}{} - } - return nil -} + validateGasAlertThresholds(col, path+".gasAlertThresholds", chain.GasAlertThresholds) -func (c RelayerChainOverride) Validate() error { - switch { - case c.ChainID == "": - return errors.New(".chainId required") - case c.TxSubmissionDelay != nil && *c.TxSubmissionDelay < 0: - return errors.New(".txSubmissionDelay must not be negative") - case c.PacketBatchSize != nil && *c.PacketBatchSize <= 0: - return errors.New(".packetBatchSize must be positive") - case c.PacketBatchTimeout != nil && *c.PacketBatchTimeout <= 0: - return errors.New(".packetBatchTimeout must be positive") - } - - if c.EVM != nil { - if err := c.EVM.Validate(); err != nil { - return errors.Wrap(err, ".evm") + if chain.ChainID != "" { + if _, ok := seen[chain.ChainID]; ok { + col.addf(path+".chainId", "duplicate chainId: %q", chain.ChainID) + } + seen[chain.ChainID] = struct{}{} } } - - return nil } -func (c RelayerEVMConfig) Validate() error { - switch { - case c.GasFeeCapMultiplier != nil && *c.GasFeeCapMultiplier <= 0: - return errors.New(".gasFeeCapMultiplier must be positive") - case c.GasTipCapMultiplier != nil && *c.GasTipCapMultiplier <= 0: - return errors.New(".gasTipCapMultiplier must be positive") - } +func (c RelayerConfig) validateClients(col *Validation) { + aliases := make(map[string]struct{}) + clients := make(map[string]struct{}) - return nil -} + for i, client := range c.Clients { + path := fmt.Sprintf("relayer.clients[%d]", i) -func (c ClientConfig) Validate() error { - switch { - case c.Alias == "": - return errors.New(".alias required") - case c.ClientID == "": - return errors.New(".clientId required") - case c.ChainID == "": - return errors.New(".chainId required") - case c.Type != ClientTypeAttestation: - return errors.Errorf(".type unknown client type: %q", c.Type) - case c.CounterpartyChainID == "": - return errors.New(".counterpartyChainId required") - case c.CounterpartyClientID == "": - return errors.New(".counterpartyClientId required") - } + c.validateClient(col, path, client) - if c.Type == ClientTypeAttestation { - if c.AttestorSet == nil { - return errors.Errorf(".attestorSet required for %s clients", ClientTypeAttestation) + if client.Alias != "" { + if _, ok := aliases[client.Alias]; ok { + col.addf(path+".alias", "duplicate alias: %q", client.Alias) + } + aliases[client.Alias] = struct{}{} } - if err := c.AttestorSet.Validate(); err != nil { - return errors.Wrap(err, ".attestorSet") + key := clientkey.Format(client.ChainID, client.ClientID) + if _, ok := clients[key]; ok { + col.addf(path+".clientId", "duplicate client %q on chain %q", client.ClientID, client.ChainID) } + clients[key] = struct{}{} } - return nil + for i, client := range c.Clients { + c.validateCounterparty(col, fmt.Sprintf("relayer.clients[%d]", i), client) + } } -func (c AttestorSetConfig) Validate() error { - if c.Threshold < 1 { - return errors.New(".threshold must be at least 1") +func (c RelayerConfig) validateClient(col *Validation, path string, client ClientConfig) { + switch { + case client.Alias == "": + col.add(path+".alias", "required") + case client.ClientID == "": + col.add(path+".clientId", "required") + case client.ChainID == "": + col.add(path+".chainId", "required") + case client.Type != ClientTypeAttestation: + col.addf(path+".type", "unknown client type: %q", client.Type) + case client.CounterpartyChainID == "": + col.add(path+".counterpartyChainId", "required") + case client.CounterpartyClientID == "": + col.add(path+".counterpartyClientId", "required") + case client.AttestorSet == nil: + col.addf(path+".attestorSet", "required for %s clients", ClientTypeAttestation) + default: + client.AttestorSet.validate(col, path+".attestorSet") } +} + +// maxAttestorThreshold bounds an attestor-set threshold: the on-chain +// attestation client takes the threshold as a uint8. +const maxAttestorThreshold = 255 - if c.Threshold > len(c.Attestors) { - return errors.Errorf(".threshold %d exceeds number of attestors %d", c.Threshold, len(c.Attestors)) +func (c AttestorSetConfig) validate(col *Validation, path string) { + switch { + case c.Threshold < 1: + col.add(path+".threshold", "must be at least 1") + case c.Threshold > len(c.Attestors): + col.addf(path+".threshold", "%d exceeds number of attestors %d", c.Threshold, len(c.Attestors)) + case c.Threshold > maxAttestorThreshold: + col.addf(path+".threshold", "must not exceed %d (uint8 on-chain), got %d", maxAttestorThreshold, c.Threshold) } seen := make(map[AttestorEntry]struct{}) for i, attestor := range c.Attestors { - if err := attestor.Validate(); err != nil { - return errors.Wrapf(err, ".attestors[%d]", i) + apath := fmt.Sprintf("%s.attestors[%d]", path, i) + + switch { + case attestor.Name == "": + col.add(apath+".name", "required") + case attestor.Type != AttestorTypeRemote && attestor.Type != AttestorTypeLocal: + col.addf(apath+".type", "unknown attestor type: %q", attestor.Type) + case attestor.Type == AttestorTypeRemote && attestor.GRPC == "": + col.add(apath+".grpc", "required for remote attestors") + // evmAddress is the attestor's on-chain signer identity. It is required for + // remote attestors (the relayer cannot derive it from a key file and the + // proof generator enforces the signer identity against it); for local + // attestors it is optional (derived from the matching attestation's signer). + case attestor.Type == AttestorTypeRemote && attestor.EVMAddress == "": + col.add(apath+".evmAddress", "required for remote attestors") + case attestor.EVMAddress != "" && !common.IsHexAddress(attestor.EVMAddress): + col.addf(apath+".evmAddress", "must be a valid hex address, got %q", attestor.EVMAddress) + // The aggregator rejects a zero expected address (it would match no recovered + // signer and freeze the set, or collide with other zero-address members), so + // reject the all-zero address here rather than letting a hex-valid zero slip + // through to construction. + case attestor.EVMAddress != "" && common.HexToAddress(attestor.EVMAddress) == (common.Address{}): + col.add(apath+".evmAddress", "must not be the zero address") } if _, ok := seen[attestor]; ok { - return errors.Errorf(".attestors duplicate entry: name %q", attestor.Name) + col.addf(apath, "duplicate entry: name %q", attestor.Name) } seen[attestor] = struct{}{} } - - return nil } -func (c AttestorEntry) Validate() error { - switch { - case c.Name == "": - return errors.New(".name required") - case c.Type != AttestorTypeRemote && c.Type != AttestorTypeLocal: - return errors.Errorf(".type unknown attestor type: %q", c.Type) - case c.Type == AttestorTypeRemote && c.GRPC == "": - return errors.New(".grpc required for remote attestors") +func (c RelayerConfig) validateCounterparty(col *Validation, path string, client ClientConfig) { + counterparty, ok := c.Client(client.CounterpartyChainID, client.CounterpartyClientID) + if !ok { + col.addf( + path+".counterpartyClientId", + "counterparty client %q on chain %q must also be configured for bi-directional relaying", + client.CounterpartyClientID, client.CounterpartyChainID, + ) + + return } - return nil + if counterparty.CounterpartyChainID != client.ChainID || counterparty.CounterpartyClientID != client.ClientID { + col.addf( + path+".counterpartyClientId", + "counterparty client %q does not reference it back", counterparty.Alias, + ) + } } -func (c RouteConfig) Validate() error { - if c.SourceClient == "" { - return errors.New(".sourceClient required") - } +func (c RelayerConfig) validateRoutes(col *Validation) { + seen := make(map[string]struct{}) - return nil + for i, route := range c.Routes { + path := fmt.Sprintf("relayer.routesToRelay[%d]", i) + + if route.SourceClient == "" { + col.add(path+".sourceClient", "required") + continue + } + + if _, ok := c.ClientByAlias(route.SourceClient); !ok { + col.addf(path+".sourceClient", "references unknown client %q", route.SourceClient) + } + + if _, ok := seen[route.SourceClient]; ok { + col.addf(path+".sourceClient", "duplicate route for client %q", route.SourceClient) + } + seen[route.SourceClient] = struct{}{} + } } diff --git a/link/internal/config/relayer_test.go b/link/internal/config/relayer_test.go index 81a41c87f..f048927c4 100644 --- a/link/internal/config/relayer_test.go +++ b/link/internal/config/relayer_test.go @@ -15,7 +15,7 @@ func TestRelayerConfig(t *testing.T) { path := filepath.Join("testdata", "sample.yml") // ACT - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) // ASSERT require.NoError(t, err) @@ -30,7 +30,19 @@ func TestRelayerConfig(t *testing.T) { assert.Equal(t, 2*time.Second, *chain.TxSubmissionDelay) assert.Equal(t, 1.5, *chain.EVM.GasFeeCapMultiplier) assert.Equal(t, 20, *chain.PacketBatchSize) - assert.Equal(t, 10*time.Second, *chain.PacketBatchTimeout) + + // gasAlertThresholds: relayer-level default plus a per-chain override. + require.NotNil(t, config.Relayer.GasAlertThresholds) + assert.Equal(t, "500000000", config.Relayer.GasAlertThresholds.WarningThreshold) + require.NotNil(t, chain.GasAlertThresholds) + assert.Equal(t, "700000000", chain.GasAlertThresholds.WarningThreshold) + assert.Equal(t, "20000000", chain.GasAlertThresholds.CriticalThreshold) + + // Chain "1" overrides; chain "8453" inherits the relayer-level default. + eff := config.Relayer.GasAlertThresholdsFor("1") + require.NotNil(t, eff) + assert.Equal(t, "700000000", eff.WarningThreshold) + assert.Equal(t, config.Relayer.GasAlertThresholds, config.Relayer.GasAlertThresholdsFor("8453")) require.Len(t, config.Relayer.Clients, 2) client := config.Relayer.Clients[0] @@ -63,7 +75,7 @@ func TestRelayerConfig(t *testing.T) { t.Run("Helpers", func(t *testing.T) { // ARRANGE path := filepath.Join("testdata", "sample.yml") - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) require.NoError(t, err) // ACT / ASSERT @@ -95,6 +107,7 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Chains = nil c.Relayer = RelayerConfig{} + c.Attestor = AttestorConfig{} }, }, { @@ -102,14 +115,14 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Chains[0].ChainID = "" }, - errContains: ".chainId required", + errContains: "chains[0].chainId: required", }, { name: "chain missing rpc", patch: func(c *Config) { c.Chains[0].EVM.RPC = "" }, - errContains: ".evm.rpc required", + errContains: "chains[0].evm.rpc: required", }, { name: "duplicate top-level chainId", @@ -138,7 +151,7 @@ func TestRelayerConfig(t *testing.T) { c.Relayer.Clients[0].ChainID = "999" c.Relayer.Clients[1].CounterpartyChainID = "999" }, - errContains: `.clients[base-0] chainId "999" not declared`, + errContains: `chainId "999" not declared`, }, { name: "counterparty chain not declared", @@ -152,7 +165,7 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Relayer.Clients[0].CounterpartyClientID = "" }, - errContains: ".counterpartyClientId required", + errContains: "counterpartyClientId: required", }, { name: "counterparty client not configured", @@ -175,7 +188,7 @@ func TestRelayerConfig(t *testing.T) { size := 0 c.Relayer.ChainOverrides[0].PacketBatchSize = &size }, - errContains: ".packetBatchSize must be positive", + errContains: "packetBatchSize: must be positive", }, { name: "negative tx submission delay", @@ -183,14 +196,56 @@ func TestRelayerConfig(t *testing.T) { delay := -time.Second c.Relayer.ChainOverrides[0].TxSubmissionDelay = &delay }, - errContains: ".txSubmissionDelay must not be negative", + errContains: "txSubmissionDelay: must not be negative", + }, + { + name: "gas alert critical exceeds warning", + patch: func(c *Config) { + c.Relayer.ChainOverrides[0].GasAlertThresholds = &GasAlertThresholds{ + WarningThreshold: "100", + CriticalThreshold: "200", + } + }, + errContains: "gasAlertThresholds.criticalThreshold: must not exceed warningThreshold", + }, + { + name: "gas alert unparseable threshold", + patch: func(c *Config) { + c.Relayer.ChainOverrides[0].GasAlertThresholds = &GasAlertThresholds{ + WarningThreshold: "lots", + CriticalThreshold: "10", + } + }, + errContains: "gasAlertThresholds.warningThreshold: must be a base-10 integer", + }, + { + name: "gas alert negative threshold", + patch: func(c *Config) { + c.Relayer.ChainOverrides[0].GasAlertThresholds = &GasAlertThresholds{ + WarningThreshold: "100", + CriticalThreshold: "-1", + } + }, + errContains: "gasAlertThresholds.criticalThreshold: must not be negative", }, { - name: "missing router contract", + name: "relayer-level gas alert critical exceeds warning", patch: func(c *Config) { - c.Chains[0].EVM.ICS26Router = "" + c.Relayer.GasAlertThresholds = &GasAlertThresholds{ + WarningThreshold: "1", + CriticalThreshold: "2", + } }, - errContains: ".evm.ics26Router required", + errContains: "relayer.gasAlertThresholds.criticalThreshold: must not exceed warningThreshold", + }, + { + // ics26Router is optional at config time (pre-deploy configs omit + // it); when present it must be a valid address. + name: "malformed router contract", + patch: func(c *Config) { + c.Chains[0].EVM.ICS26Router = "0xnothex" + }, + errContains: "chains[0].evm.ics26Router: must be a valid hex address", }, { name: "zero gas multiplier", @@ -198,21 +253,21 @@ func TestRelayerConfig(t *testing.T) { zero := 0.0 c.Relayer.ChainOverrides[0].EVM.GasFeeCapMultiplier = &zero }, - errContains: ".gasFeeCapMultiplier must be positive", + errContains: "gasFeeCapMultiplier: must be positive", }, { name: "client missing clientId", patch: func(c *Config) { c.Relayer.Clients[0].ClientID = "" }, - errContains: ".clientId required", + errContains: "clientId: required", }, { name: "client missing chainId", patch: func(c *Config) { c.Relayer.Clients[0].ChainID = "" }, - errContains: ".chainId required", + errContains: "clients[0].chainId: required", }, { name: "unsupported client type", @@ -228,49 +283,49 @@ func TestRelayerConfig(t *testing.T) { duplicate.Alias = "eth-to-base-2" c.Relayer.Clients = append(c.Relayer.Clients, duplicate) }, - errContains: `.clients duplicate client "base-0" on chain "1"`, + errContains: `duplicate client "base-0" on chain "1"`, }, { name: "client without attestor set", patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet = nil }, - errContains: `.attestorSet required for attestation clients`, + errContains: `attestorSet: required for attestation clients`, }, { name: "client missing alias", patch: func(c *Config) { c.Relayer.Clients[0].Alias = "" }, - errContains: ".alias required", + errContains: "clients[0].alias: required", }, { name: "duplicate client alias", patch: func(c *Config) { c.Relayer.Clients[1].Alias = "eth-to-base" }, - errContains: `.clients duplicate alias`, + errContains: `duplicate alias`, }, { name: "threshold exceeds attestors", patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet.Threshold = 4 }, - errContains: `threshold 4 exceeds number of attestors 3`, + errContains: `threshold: 4 exceeds number of attestors 3`, }, { name: "zero threshold", patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet.Threshold = 0 }, - errContains: ".threshold must be at least 1", + errContains: "threshold: must be at least 1", }, { name: "remote attestor missing grpc", patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet.Attestors[0].GRPC = "" }, - errContains: ".grpc required for remote attestors", + errContains: "grpc: required for remote attestors", }, { name: "invalid attestor type", @@ -284,7 +339,7 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet.Attestors[0].Name = "" }, - errContains: ".name required", + errContains: "name: required", }, { name: "duplicate attestor name routed to a different grpc endpoint", @@ -297,14 +352,14 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Relayer.Clients[0].AttestorSet.Attestors[1] = c.Relayer.Clients[0].AttestorSet.Attestors[0] }, - errContains: ".attestors duplicate entry", + errContains: "duplicate entry", }, { name: "route missing sourceClient", patch: func(c *Config) { c.Relayer.Routes[0].SourceClient = "" }, - errContains: ".sourceClient required", + errContains: "sourceClient: required", }, { name: "route references unknown client", @@ -318,19 +373,19 @@ func TestRelayerConfig(t *testing.T) { patch: func(c *Config) { c.Relayer.Routes = append(c.Relayer.Routes, c.Relayer.Routes[0]) }, - errContains: ".routesToRelay duplicate route", + errContains: "duplicate route", }, } { t.Run(tt.name, func(t *testing.T) { // ARRANGE path := filepath.Join("testdata", "sample.yml") - config, err := LoadFromFile(path, true, true) + config, err := LoadFromFile(path, true) require.NoError(t, err) tt.patch(&config) // ACT - err = config.Validate() + err = config.Validate().Err() // ASSERT if tt.errContains != "" { @@ -344,3 +399,31 @@ func TestRelayerConfig(t *testing.T) { }) } + +func TestGasAlertThresholdsParse(t *testing.T) { + t.Run("parses wei bounds beyond uint64", func(t *testing.T) { + // ARRANGE: a warning threshold larger than math.MaxUint64. + thresholds := GasAlertThresholds{ + WarningThreshold: "20000000000000000000", + CriticalThreshold: "1000000000000000000", + } + + // ACT + warning, critical, err := thresholds.Parse() + + // ASSERT + require.NoError(t, err) + assert.Equal(t, "20000000000000000000", warning.String()) + assert.Equal(t, "1000000000000000000", critical.String()) + }) + + t.Run("rejects critical above warning", func(t *testing.T) { + _, _, err := GasAlertThresholds{WarningThreshold: "10", CriticalThreshold: "20"}.Parse() + require.ErrorContains(t, err, "must not exceed warningThreshold") + }) + + t.Run("rejects unparseable bound", func(t *testing.T) { + _, _, err := GasAlertThresholds{WarningThreshold: "10", CriticalThreshold: "nope"}.Parse() + require.ErrorContains(t, err, "criticalThreshold: must be a base-10 integer") + }) +} diff --git a/link/internal/config/testdata/relayer.key b/link/internal/config/testdata/relayer.key new file mode 100644 index 000000000..0c4e3f9dd --- /dev/null +++ b/link/internal/config/testdata/relayer.key @@ -0,0 +1 @@ +{"type":"ecdsa","privateKeyBase64":"rAl0vsOaF+NrpKa00jj/lEustHjL7V78rnhNe/Ty/4A="} diff --git a/link/internal/config/testdata/sample.yml b/link/internal/config/testdata/sample.yml index 4b185a18f..d22878620 100644 --- a/link/internal/config/testdata/sample.yml +++ b/link/internal/config/testdata/sample.yml @@ -13,6 +13,10 @@ chains: rpc: https://base-rpc.example.com ics26Router: "0xe20BccD900Fa1B48f46F5a483d9De063b07eDFCC" relayer: + signer: "my-relayer" + gasAlertThresholds: + warningThreshold: "500000000" + criticalThreshold: "10000000" chainOverrides: - chainId: "1" evm: @@ -20,7 +24,9 @@ relayer: gasTipCapMultiplier: 1.5 txSubmissionDelay: 2s packetBatchSize: 20 - packetBatchTimeout: 10s + gasAlertThresholds: + warningThreshold: "700000000" + criticalThreshold: "20000000" - chainId: "8453" clients: - alias: "eth-to-base" @@ -36,11 +42,16 @@ relayer: - name: "attestor-alice-base" type: remote grpc: attestor-alice.example.com:3000 + evmAddress: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" - name: "attestor-bob-base" type: remote grpc: attestor-bob.example.com:3000 + evmAddress: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" - name: "attestor-dan-base" type: local + # my-attestor is a remote (KMS) signer, so the address cannot be derived + # from a local key file; it must be given explicitly. + evmAddress: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" - alias: "base-to-eth" clientId: "ethereum-0" chainId: "8453" @@ -52,9 +63,36 @@ relayer: attestors: - name: "attestor-dan-ethereum" type: local + # my-attestor-eth is a remote (KMS) signer; supply the address explicitly. + evmAddress: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" routesToRelay: - sourceClient: "eth-to-base" autoRelay: enabled: false lookback: 100 - sourceClient: "base-to-eth" +attestor: + attestations: + - chainId: "8453" + name: "attestor-dan-base" + signer: "my-attestor" + evm: + routerAddress: "0x5FbDB2315678afecb367f032d93F642f64180aa3" + finalityOffset: 1 + - chainId: "1" + name: "attestor-dan-ethereum" + signer: "my-attestor-eth" + evm: + routerAddress: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" +signers: + - alias: "my-relayer" + type: local + file: testdata/relayer.key + - alias: "my-attestor" + type: remote + grpc: signer.example.com:3000 + remoteKeyId: attestor-key + - alias: "my-attestor-eth" + type: remote + grpc: signer.example.com:3000 + remoteKeyId: attestor-eth-key diff --git a/link/internal/config/validate.go b/link/internal/config/validate.go new file mode 100644 index 000000000..0bcee940f --- /dev/null +++ b/link/internal/config/validate.go @@ -0,0 +1,351 @@ +package config + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/link/internal/network" +) + +// ValidationIssue identifies one config problem at its schema path. +type ValidationIssue struct { + Path string `json:"path"` + Message string `json:"message"` +} + +// Validation contains every structural issue found in a config. +type Validation struct { + Errors []ValidationIssue + Warnings []ValidationIssue +} + +func (v *Validation) add(path, message string) { + v.Errors = append(v.Errors, ValidationIssue{Path: path, Message: message}) +} + +func (v *Validation) addf(path, format string, args ...any) { + v.add(path, fmt.Sprintf(format, args...)) +} + +// warnf records a non-fatal finding: something an operator should know about +// but that reflects the environment the config runs in rather than the config +// itself, so it must not fail validation on a machine other than the target. +func (v *Validation) warnf(path, format string, args ...any) { + v.Warnings = append(v.Warnings, ValidationIssue{Path: path, Message: fmt.Sprintf(format, args...)}) +} + +// Err returns the first validation error, formatted as ": ". +func (v Validation) Err() error { + if len(v.Errors) == 0 { + return nil + } + + return fmt.Errorf("%s: %s", v.Errors[0].Path, v.Errors[0].Message) +} + +// ChainIDs returns the configured chain ids in declared (file) order. +func (c Config) ChainIDs() []string { + ids := make([]string, 0, len(c.Chains)) + for _, chain := range c.Chains { + ids = append(ids, chain.ChainID) + } + + return ids +} + +// Validate returns every structural error and warning in the config. +func (c Config) Validate() Validation { + validation := newValidation() + c.validate(&validation) + + return validation +} + +func newValidation() Validation { + return Validation{ + Errors: []ValidationIssue{}, + Warnings: []ValidationIssue{}, + } +} + +func (c Config) validate(col *Validation) { + c.validateServer(col) + c.DB.validate(col) + c.validateChains(col) + c.Relayer.validate(col) + c.validateAttestor(col) + c.validateSigners(col) + c.crossValidate(col) +} + +func (c Config) validateServer(col *Validation) { + if err := network.ValidateListenAddr(c.Server.ListenAddress); err != nil { + col.addf("server.listenAddr", "%v", err) + } +} + +// Validate returns every structural error in the database config. +func (c DBConfig) Validate() Validation { + validation := newValidation() + c.validate(&validation) + + return validation +} + +func (c DBConfig) validate(col *Validation) { + switch { + case c.Type != DBTypeSQLite && c.Type != DBTypePostgres: + col.addf("db.type", "must be one of [%q, %q], got %q", DBTypeSQLite, DBTypePostgres, c.Type) + case c.Type == DBTypeSQLite && c.URL == sqliteInMemory: + col.add("db.url", "must not be :memory: for sqlite") + case c.URL == "": + col.add("db.url", "must not be empty") + } +} + +func (c Config) validateChains(col *Validation) { + seen := make(map[string]struct{}) + + for i, chain := range c.Chains { + path := fmt.Sprintf("chains[%d]", i) + + if chain.ChainID == "" { + col.add(path+".chainId", "required") + } + + // Each chain must configure exactly one supported type block. EVM is the + // only supported type today, so a chain with no evm block passes no + // structural rule but fails at bootstrap (manager "unsupported chain + // type"); reject it here with a clear path instead. + if chain.EVM == nil { + col.add(path+".evm", "required (chain must configure a supported type block)") + } else { + if chain.EVM.RPC == "" { + col.add(path+".evm.rpc", "required") + } + // ics26Router is optional at config time: pre-deploy configs do not yet + // have it (`ibc deploy`/`ibc connect` fills it in). When present it must + // be a valid address; the daemon enforces non-emptiness at startup. + if chain.EVM.ICS26Router != "" && !common.IsHexAddress(chain.EVM.ICS26Router) { + col.addf(path+".evm.ics26Router", "must be a valid hex address, got %q", chain.EVM.ICS26Router) + } + } + + if chain.ChainID != "" { + if _, ok := seen[chain.ChainID]; ok { + col.addf(path+".chainId", "duplicate chainId: %q", chain.ChainID) + } + seen[chain.ChainID] = struct{}{} + } + } +} + +func (c Config) validateAttestor(col *Validation) { + nameSet := make(map[string]struct{}) + signerSet := make(map[string]struct{}) + + for i, attestation := range c.Attestor.Attestations { + path := fmt.Sprintf("attestor.attestations[%d]", i) + + switch { + case attestation.ChainID == "": + col.add(path+".chainId", "required") + case attestation.Name == "": + col.add(path+".name", "required") + case attestation.Signer == "": + col.add(path+".signer", "required") + case attestation.EVM == nil: + col.add(path+".evm", "required") + // routerAddress is optional at config time: greenfield combined configs do + // not yet have it (`ibc connect --write-config` fills it in). When present it + // must be a valid address; the attestor daemon enforces non-emptiness at + // startup (adapter construction). + case attestation.EVM.RouterAddress != "" && !common.IsHexAddress(attestation.EVM.RouterAddress): + col.addf( + path+".evm.routerAddress", + "must be a valid hex address, got %q", + attestation.EVM.RouterAddress, + ) + } + + if attestation.Name != "" { + if _, ok := nameSet[attestation.Name]; ok { + col.addf(path+".name", "duplicate name: %q", attestation.Name) + } + nameSet[attestation.Name] = struct{}{} + } + + if attestation.Signer != "" { + if _, ok := signerSet[attestation.Signer]; ok { + col.addf(path+".signer", "duplicate signer: %q", attestation.Signer) + } + signerSet[attestation.Signer] = struct{}{} + } + } +} + +func (c Config) validateSigners(col *Validation) { + set := make(map[string]struct{}) + + for i, signer := range c.Signers { + path := fmt.Sprintf("signers[%d]", i) + + switch { + case signer.Alias == "": + col.add(path+".alias", "required") + case signer.Type == "": + col.add(path+".type", "required") + case signer.Type != SignerLocal && signer.Type != SignerRemote: + col.addf(path+".type", "must be one of [%q, %q], got %q", SignerLocal, SignerRemote, signer.Type) + case signer.Type == SignerLocal && signer.File == "": + col.add(path+".file", "required for local signer") + case signer.Type == SignerRemote && signer.GRPC == "": + col.add(path+".grpc", "required for remote signer") + case signer.Type == SignerRemote && signer.RemoteKeyID == "": + col.add(path+".remoteKeyId", "required for remote signer") + case signer.Type == SignerLocal: + if expanded, err := ExpandHome(signer.File); err != nil { + col.addf(fmt.Sprintf("signers[%d].file", i), "%v", err) + } else if err := fileExists(expanded); err != nil { + // The key file's presence is machine state, not config correctness, + // so a missing file warns instead of failing validation. + col.warnf( + path+".file", + "%v (the key file must exist when the daemon starts; generate it with `ibc keys new`)", + err, + ) + } + } + + if signer.Alias != "" { + if _, ok := set[signer.Alias]; ok { + col.addf(path+".alias", "duplicate alias: %q", signer.Alias) + } + set[signer.Alias] = struct{}{} + } + } +} + +func (c Config) crossValidate(col *Validation) { + signerSet := make(map[string]struct{}, len(c.Signers)) + signerTypeByAlias := make(map[string]string, len(c.Signers)) + for _, signer := range c.Signers { + signerSet[signer.Alias] = struct{}{} + signerTypeByAlias[signer.Alias] = signer.Type + } + + for i, attestation := range c.Attestor.Attestations { + if attestation.Signer != "" { + if _, ok := signerSet[attestation.Signer]; !ok { + col.addf( + fmt.Sprintf("attestor.attestations[%d].signer", i), + "references unknown signer: %q", attestation.Signer, + ) + } + } + + if _, ok := c.Chain(attestation.ChainID); attestation.ChainID != "" && !ok { + col.addf( + fmt.Sprintf("attestor.attestations[%d].chainId", i), + "chainId %q not declared in top-level chains", attestation.ChainID, + ) + } + } + + for i, override := range c.Relayer.ChainOverrides { + if _, ok := c.Chain(override.ChainID); override.ChainID != "" && !ok { + col.addf( + fmt.Sprintf("relayer.chainOverrides[%d].chainId", i), + "chainId %q not declared in top-level chains", override.ChainID, + ) + } + } + + for i, client := range c.Relayer.Clients { + if _, ok := c.Chain(client.ChainID); client.ChainID != "" && !ok { + col.addf( + fmt.Sprintf("relayer.clients[%d].chainId", i), + "chainId %q not declared in top-level chains", client.ChainID, + ) + } + } + + if len(c.Relayer.Clients) > 0 { + _, ok := signerSet[c.Relayer.Signer] + switch { + case c.Relayer.Signer == "": + col.add("relayer.signer", "required when relayer.clients is non-empty") + case !ok: + col.addf("relayer.signer", "references unknown signer: %q", c.Relayer.Signer) + case signerTypeByAlias[c.Relayer.Signer] != SignerLocal: + // evm tx signing needs the raw secp256k1 key (RLP-digest signature), which + // only a local signer exposes; the daemon rejects a remote relayer signer + // at bootstrap (evmtx.go). Remote KMS tx signing is not implemented in V1. + col.addf( + "relayer.signer", + "must reference a local signer (remote KMS tx signing is not implemented in V1), but %q has type %q", + c.Relayer.Signer, signerTypeByAlias[c.Relayer.Signer], + ) + } + } + + attestationChainByName := make(map[string]string, len(c.Attestor.Attestations)) + attestationSignerByName := make(map[string]string, len(c.Attestor.Attestations)) + for _, attestation := range c.Attestor.Attestations { + attestationChainByName[attestation.Name] = attestation.ChainID + attestationSignerByName[attestation.Name] = attestation.Signer + } + + for i, client := range c.Relayer.Clients { + if client.AttestorSet == nil { + continue + } + + for j, attestor := range client.AttestorSet.Attestors { + if attestor.Type != AttestorTypeLocal || attestor.Name == "" { + continue + } + + chainID, ok := attestationChainByName[attestor.Name] + if !ok { + col.addf( + fmt.Sprintf("relayer.clients[%d].attestorSet.attestors[%d].name", i, j), + "local attestor %q not declared in attestor.attestations", attestor.Name, + ) + + continue + } + + // A local attestor derives its on-chain address from its backing + // attestation's signer key file — but only a local signer exposes one. A + // KMS-backed (remote) signer has no locally derivable address, so proofgen + // construction rejects such an entry unless it carries an explicit + // evmAddress. Require it here so the misconfiguration is caught at validate + // time rather than at daemon startup. + signerAlias := attestationSignerByName[attestor.Name] + if attestor.EVMAddress == "" && signerTypeByAlias[signerAlias] != SignerLocal { + col.addf( + fmt.Sprintf("relayer.clients[%d].attestorSet.attestors[%d].evmAddress", i, j), + "local attestor %q is backed by non-local signer %q (KMS-backed local attestations "+ + "cannot derive an address from a key file); set evmAddress to its on-chain address", + attestor.Name, signerAlias, + ) + } + + // A local attestor binds to an attestation by name only, but the signed + // attestation carries no chain id: if that attestation attests a + // different chain than the client's counterparty, the light client + // cannot tell it is being fed state from an unrelated chain. Require the + // bound attestation's chainId to match the client's counterpartyChainId. + // (Both empty cases are already flagged as required elsewhere.) + if chainID != "" && client.CounterpartyChainID != "" && chainID != client.CounterpartyChainID { + col.addf( + fmt.Sprintf("relayer.clients[%d].attestorSet.attestors[%d].name", i, j), + "local attestor %q attests chainId %q but client counterpartyChainId is %q", + attestor.Name, chainID, client.CounterpartyChainID, + ) + } + } + } +} diff --git a/link/internal/config/validate_test.go b/link/internal/config/validate_test.go new file mode 100644 index 000000000..6ff281863 --- /dev/null +++ b/link/internal/config/validate_test.go @@ -0,0 +1,490 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// twoChainConfig returns a valid baseline Config shared by tests that need two +// EVM chains ("1" with RPC http://x, "2" with RPC http://y) and nothing else +// configured (no router). Every call returns a fresh Config, so callers are +// free to mutate the result (including replacing cfg.Chains outright) without +// affecting other tests. +func twoChainConfig() Config { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Chains = []ChainConfig{ + {ChainID: "1", EVM: &EVMChainConfig{RPC: "http://x", ICS26Router: "0x0000000000000000000000000000000000000000"}}, + {ChainID: "2", EVM: &EVMChainConfig{RPC: "http://y", ICS26Router: "0x0000000000000000000000000000000000000000"}}, + } + + return cfg +} + +// hasError reports whether errs contains an entry with the given path whose msg +// contains msgSubstr. +func hasError(errs []ValidationIssue, path, msgSubstr string) bool { + for _, e := range errs { + if e.Path == path && strings.Contains(e.Message, msgSubstr) { + return true + } + } + + return false +} + +func hasWarning(warns []ValidationIssue, path, msgSubstr string) bool { + for _, w := range warns { + if w.Path == path && strings.Contains(w.Message, msgSubstr) { + return true + } + } + + return false +} + +func TestValidate(t *testing.T) { + t.Run("validDefaultIsEmpty", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + require.Empty(t, cfg.Validate().Errors) + }) + + t.Run("collectsMultipleErrors", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "invalid" + cfg.DB.Type = "mysql" + cfg.Chains = []ChainConfig{ + {ChainID: "", EVM: &EVMChainConfig{}}, + } + + errs := cfg.Validate().Errors + + // all three blocks report independently (not fail-fast) + assert.True(t, hasError(errs, "server.listenAddr", ""), "server error: %+v", errs) + assert.True(t, hasError(errs, "db.type", "must be one of"), "db error: %+v", errs) + assert.True(t, hasError(errs, "chains[0].chainId", "required"), "chainId: %+v", errs) + assert.True(t, hasError(errs, "chains[0].evm.rpc", "required"), "rpc: %+v", errs) + // ics26Router is optional at config time; an empty router must NOT error. + assert.False(t, hasError(errs, "chains[0].evm.ics26Router", ""), "router: %+v", errs) + }) + + t.Run("dbUrlEmpty", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.DB.URL = "" + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "db.url", "must not be empty"), "%+v", errs) + }) + + t.Run("chainDuplicate", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Chains = []ChainConfig{ + {ChainID: "1", EVM: &EVMChainConfig{RPC: "http://x", ICS26Router: "0x0000000000000000000000000000000000000000"}}, + {ChainID: "1", EVM: &EVMChainConfig{RPC: "http://y", ICS26Router: "0x0000000000000000000000000000000000000000"}}, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "chains[1].chainId", "duplicate chainId"), "%+v", errs) + }) + + t.Run("signerPaths", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Signers = Signers{ + {Alias: "", Type: SignerLocal}, + {Alias: "b", Type: "bogus"}, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "signers[0].alias", "required"), "%+v", errs) + assert.True(t, hasError(errs, "signers[1].type", "must be one of"), "%+v", errs) + }) + + t.Run("attestorPathsAndCrossRefs", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Attestor = AttestorConfig{ + Attestations: []AttestationConfig{ + { + ChainID: "missing-chain", + Name: "a", + Signer: "missing-signer", + EVM: &AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000000"}, + }, + }, + } + + errs := cfg.Validate().Errors + // cross-ref errors are reported at the referencing field's path + assert.True(t, hasError(errs, "attestor.attestations[0].signer", "unknown signer"), "%+v", errs) + assert.True(t, hasError(errs, "attestor.attestations[0].chainId", "not declared"), "%+v", errs) + }) + + t.Run("attestorEvmRequired", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Chains = []ChainConfig{{ChainID: "chain-a", EVM: &EVMChainConfig{RPC: "http://x", ICS26Router: "0x0000000000000000000000000000000000000000"}}} + cfg.Signers = Signers{{Alias: "s", Type: SignerRemote, GRPC: "grpc://x", RemoteKeyID: "k"}} + cfg.Attestor = AttestorConfig{ + Attestations: []AttestationConfig{{ChainID: "chain-a", Name: "a", Signer: "s"}}, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "attestor.attestations[0].evm", "required"), "%+v", errs) + }) + + t.Run("relayerClientAndRoutePaths", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Chains = []ChainConfig{{ChainID: "1", EVM: &EVMChainConfig{RPC: "http://x", ICS26Router: "0x0000000000000000000000000000000000000000"}}} + cfg.Relayer = RelayerConfig{ + ChainOverrides: []RelayerChainOverride{{ChainID: "999"}}, + Clients: []ClientConfig{ + {Alias: "c", ClientID: "", ChainID: "1", Type: ClientTypeAttestation}, + }, + Routes: []RouteConfig{{SourceClient: "unknown"}}, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "relayer.chainOverrides[0].chainId", "not declared"), "%+v", errs) + assert.True(t, hasError(errs, "relayer.clients[0].clientId", "required"), "%+v", errs) + assert.True(t, hasError(errs, "relayer.routesToRelay[0].sourceClient", "unknown client"), "%+v", errs) + }) + + t.Run("attestorSetThresholdPath", func(t *testing.T) { + cfg := twoChainConfig() + cfg.Relayer = RelayerConfig{ + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 5, + Attestors: []AttestorEntry{{Name: "n", Type: AttestorTypeLocal}}, + }, + }, + }, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "relayer.clients[0].attestorSet.threshold", "exceeds"), "%+v", errs) + }) + + t.Run("attestorSetThresholdExceedsUint8", func(t *testing.T) { + cfg := twoChainConfig() + + // 256 attestors so the threshold does not fail the "exceeds attestors" rule + // first: 256 is a valid count but still exceeds the on-chain uint8 max (255). + attestors := make([]AttestorEntry, 256) + for i := range attestors { + attestors[i] = AttestorEntry{Name: fmt.Sprintf("att-%d", i), Type: AttestorTypeRemote, GRPC: "localhost:9999"} + } + cfg.Relayer = RelayerConfig{ + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{Threshold: 256, Attestors: attestors}, + }, + }, + } + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "relayer.clients[0].attestorSet.threshold", "must not exceed 255"), "%+v", errs) + }) + + t.Run("localAttestorMustReferenceAttestation", func(t *testing.T) { + cfg := twoChainConfig() + cfg.Signers = []SignerConfig{{Alias: "s", Type: SignerLocal, File: "k.json"}} + cfg.Attestor = AttestorConfig{Attestations: []AttestationConfig{{ + ChainID: "2", Name: "att-b", Signer: "s", + EVM: &AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000000"}, + }}} + cfg.Relayer = RelayerConfig{ + Signer: "s", + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{{Name: "typo", Type: AttestorTypeLocal}}, + }, + }, + }, + } + + errs := cfg.Validate().Errors + assert.True( + t, + hasError(errs, "relayer.clients[0].attestorSet.attestors[0].name", "not declared in attestor.attestations"), + "%+v", errs, + ) + + cfg.Relayer.Clients[0].AttestorSet.Attestors[0].Name = "att-b" + errs = cfg.Validate().Errors + assert.False( + t, + hasError(errs, "relayer.clients[0].attestorSet.attestors[0].name", "not declared"), + "%+v", errs, + ) + }) + + t.Run("localAttestorMustMatchCounterpartyChain", func(t *testing.T) { + // Base config: client on chain "1" with counterparty chain "2", referencing a + // local attestor "att" that attests some chain. + newCfg := func(attestationChainID string) Config { + cfg := twoChainConfig() + cfg.Signers = []SignerConfig{{Alias: "s", Type: SignerLocal, File: "k.json"}} + cfg.Attestor = AttestorConfig{Attestations: []AttestationConfig{{ + ChainID: attestationChainID, Name: "att", Signer: "s", + EVM: &AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000000"}, + }}} + cfg.Relayer = RelayerConfig{ + Signer: "s", + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{{Name: "att", Type: AttestorTypeLocal}}, + }, + }, + }, + } + + return cfg + } + + // Wrong chain: attestation attests chain "1" but the client's counterparty is + // chain "2" -> the local attestor would sign state from an unrelated chain. + errs := newCfg("1").Validate().Errors + assert.True( + t, + hasError(errs, "relayer.clients[0].attestorSet.attestors[0].name", "counterpartyChainId is"), + "%+v", errs, + ) + + // Matching chain: attestation attests the counterparty chain "2" -> valid. + errs = newCfg("2").Validate().Errors + assert.False( + t, + hasError(errs, "relayer.clients[0].attestorSet.attestors[0].name", "counterpartyChainId is"), + "%+v", errs, + ) + }) +} + +func TestValidationErr(t *testing.T) { + t.Run("nilWhenValid", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + + validation := cfg.Validate() + require.Empty(t, validation.Errors) + require.NoError(t, validation.Err()) + }) + + t.Run("returnsFirstError", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "invalid" + cfg.DB.Type = "mysql" + + err := cfg.Validate().Err() + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), "server.listenAddr: "), "got %q", err.Error()) + require.NotContains(t, err.Error(), "db.type") + }) +} + +func TestChainIDsOrder(t *testing.T) { + cfg := Config{Chains: []ChainConfig{{ChainID: "b"}, {ChainID: "a"}, {ChainID: "c"}}} + assert.Equal(t, []string{"b", "a", "c"}, cfg.ChainIDs()) +} + +// TestChainRequiresSupportedTypeBlock verifies a chain entry with no supported +// type block (evm == nil) is rejected structurally instead of only failing later +// at bootstrap ("unsupported chain type"). +func TestChainRequiresSupportedTypeBlock(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ListenAddress = "127.0.0.1:8080" + cfg.Chains = []ChainConfig{{ChainID: "1"}} + + errs := cfg.Validate().Errors + assert.True(t, hasError(errs, "chains[0].evm", "required"), "%+v", errs) +} + +// TestRelayerSignerMustBeLocal verifies that when relayer.clients is non-empty +// the referenced relayer signer must be a local signer: evm tx signing needs the +// raw key, so a remote signer is rejected here rather than at bootstrap. +func TestRelayerSignerMustBeLocal(t *testing.T) { + keyFile := filepath.Join(t.TempDir(), "relayer.key") + require.NoError(t, os.WriteFile(keyFile, []byte("{}"), 0o600)) + + newCfg := func(signerType, file string) Config { + cfg := twoChainConfig() + cfg.Signers = []SignerConfig{{ + Alias: "relay", Type: signerType, File: file, GRPC: "grpc://x", RemoteKeyID: "k", + }} + cfg.Relayer = RelayerConfig{ + Signer: "relay", + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{{ + Name: "att", Type: AttestorTypeRemote, GRPC: "localhost:9999", + EVMAddress: "0x0000000000000000000000000000000000000001", + }}, + }, + }, + { + Alias: "b", ClientID: "cb", ChainID: "2", + CounterpartyChainID: "1", CounterpartyClientID: "ca", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{{ + Name: "att2", Type: AttestorTypeRemote, GRPC: "localhost:9998", + EVMAddress: "0x0000000000000000000000000000000000000002", + }}, + }, + }, + }, + } + + return cfg + } + + t.Run("remoteRejected", func(t *testing.T) { + errs := newCfg(SignerRemote, "").Validate().Errors + assert.True(t, hasError(errs, "relayer.signer", "must reference a local signer"), "%+v", errs) + }) + + t.Run("localAccepted", func(t *testing.T) { + errs := newCfg(SignerLocal, keyFile).Validate().Errors + assert.False(t, hasError(errs, "relayer.signer", "must reference a local signer"), "%+v", errs) + }) +} + +// TestRemoteAttestorRequiresEVMAddress verifies a remote attestor entry must +// carry an evmAddress (the proof generator enforces signer identity against it), +// while a local attestor may omit it. +func TestRemoteAttestorRequiresEVMAddress(t *testing.T) { + newCfg := func(attestor AttestorEntry) Config { + cfg := twoChainConfig() + cfg.Relayer = RelayerConfig{ + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{attestor}, + }, + }, + }, + } + + return cfg + } + + t.Run("remoteMissingEVMAddress", func(t *testing.T) { + errs := newCfg(AttestorEntry{Name: "att", Type: AttestorTypeRemote, GRPC: "localhost:9999"}).Validate().Errors + assert.True(t, hasError(errs, "relayer.clients[0].attestorSet.attestors[0].evmAddress", "required for remote"), "%+v", errs) + }) + + t.Run("remoteMalformedEVMAddress", func(t *testing.T) { + errs := newCfg(AttestorEntry{ + Name: "att", Type: AttestorTypeRemote, GRPC: "localhost:9999", EVMAddress: "0xnothex", + }).Validate().Errors + assert.True(t, hasError(errs, "relayer.clients[0].attestorSet.attestors[0].evmAddress", "valid hex"), "%+v", errs) + }) + + t.Run("localMayOmitEVMAddress", func(t *testing.T) { + errs := newCfg(AttestorEntry{Name: "att", Type: AttestorTypeLocal}).Validate().Errors + assert.False(t, hasError(errs, "relayer.clients[0].attestorSet.attestors[0].evmAddress", ""), "%+v", errs) + }) + + t.Run("zeroEVMAddressRejected", func(t *testing.T) { + // A hex-valid but all-zero address passes the syntax check yet is rejected by + // aggregator construction; reject it at config time too. + errs := newCfg(AttestorEntry{ + Name: "att", Type: AttestorTypeRemote, GRPC: "localhost:9999", + EVMAddress: "0x0000000000000000000000000000000000000000", + }).Validate().Errors + assert.True(t, hasError(errs, "relayer.clients[0].attestorSet.attestors[0].evmAddress", "zero address"), "%+v", errs) + }) +} + +// TestLocalAttestorWithRemoteSignerRequiresEVMAddress verifies a local attestor +// entry whose backing attestation is signed by a remote (KMS) signer must carry an +// explicit evmAddress: proofgen cannot derive the on-chain address from a key file, +// so the cross-validation flags the misconfiguration at validate time. +func TestLocalAttestorWithRemoteSignerRequiresEVMAddress(t *testing.T) { + // newCfg builds a client on chain "1" (counterparty "2") with a single local + // attestor "att" bound to an attestation signed by the given signer type. + newCfg := func(signerType string, entry AttestorEntry) Config { + cfg := twoChainConfig() + cfg.Signers = []SignerConfig{{ + Alias: "s", Type: signerType, File: "k.json", GRPC: "grpc://x", RemoteKeyID: "k", + }} + cfg.Attestor = AttestorConfig{Attestations: []AttestationConfig{{ + ChainID: "2", Name: "att", Signer: "s", + EVM: &AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000000"}, + }}} + cfg.Relayer = RelayerConfig{ + Clients: []ClientConfig{ + { + Alias: "a", ClientID: "ca", ChainID: "1", + CounterpartyChainID: "2", CounterpartyClientID: "cb", + Type: ClientTypeAttestation, + AttestorSet: &AttestorSetConfig{ + Threshold: 1, + Attestors: []AttestorEntry{entry}, + }, + }, + }, + } + + return cfg + } + + const path = "relayer.clients[0].attestorSet.attestors[0].evmAddress" + + t.Run("remoteSignerNoEVMAddressRejected", func(t *testing.T) { + errs := newCfg(SignerRemote, AttestorEntry{Name: "att", Type: AttestorTypeLocal}).Validate().Errors + assert.True(t, hasError(errs, path, "backed by non-local signer"), "%+v", errs) + }) + + t.Run("remoteSignerWithEVMAddressAccepted", func(t *testing.T) { + errs := newCfg(SignerRemote, AttestorEntry{ + Name: "att", Type: AttestorTypeLocal, + EVMAddress: "0x00000000000000000000000000000000000000Aa", + }).Validate().Errors + assert.False(t, hasError(errs, path, "backed by non-local signer"), "%+v", errs) + }) + + t.Run("localSignerMayOmitEVMAddress", func(t *testing.T) { + errs := newCfg(SignerLocal, AttestorEntry{Name: "att", Type: AttestorTypeLocal}).Validate().Errors + assert.False(t, hasError(errs, path, "backed by non-local signer"), "%+v", errs) + }) +} diff --git a/link/internal/deploy/account.go b/link/internal/deploy/account.go new file mode 100644 index 000000000..7480b617f --- /dev/null +++ b/link/internal/deploy/account.go @@ -0,0 +1,91 @@ +package deploy + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +// Account is a local ECDSA deployer identity. It signs contract-creation and +// state transactions on an EVM chain. Remote (KMS) signers are intentionally +// unsupported: deploy needs a raw key to drive go-ethereum's bind transactor, +// which does not accept an out-of-process signer. +type Account struct { + key *ecdsa.PrivateKey + addr common.Address +} + +// AccountFromECDSA wraps an existing ECDSA private key. +func AccountFromECDSA(key *ecdsa.PrivateKey) Account { + return Account{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} +} + +// AccountFromPrivateKeyBytes builds an Account from raw secp256k1 private key +// bytes (as stored in a local signer key file). +func AccountFromPrivateKeyBytes(privateKey []byte) (Account, error) { + key, err := crypto.ToECDSA(privateKey) + if err != nil { + return Account{}, fmt.Errorf("parse secp256k1 private key: %w", err) + } + return AccountFromECDSA(key), nil +} + +// Address returns the account's 20-byte EVM address. +func (a Account) Address() common.Address { return a.addr } + +// TransactOpts builds a chain-scoped, key-signing transactor. +func (a Account) TransactOpts(chainID *big.Int) (*bind.TransactOpts, error) { + if a.key == nil { + return nil, fmt.Errorf("account has no private key") + } + opts, err := bind.NewKeyedTransactorWithChainID(a.key, chainID) + if err != nil { + return nil, fmt.Errorf("create transactor: %w", err) + } + return opts, nil +} + +// resolveLocalAccount loads the local secp256k1 (ECDSA) account for a signer +// alias. deploy needs a raw key to drive go-ethereum's bind transactor, so both +// the deployer and any local attestor address resolve through here. Config +// validation guarantees a local signer has a non-empty, existing key File, so +// there is no home-relative fallback. +func resolveLocalAccount(cfg config.Config, alias string) (Account, error) { + sc, ok := signerByAlias(cfg, alias) + if !ok { + return Account{}, fmt.Errorf("signer %q not found in config signers", alias) + } + if sc.Type != config.SignerLocal { + return Account{}, fmt.Errorf("signer %q is %q, but a local secp256k1 signer is required", alias, sc.Type) + } + + path, err := config.ExpandHome(sc.File) + if err != nil { + return Account{}, fmt.Errorf("resolve key file for signer %q: %w", alias, err) + } + key, err := signer.LocalKeyFromFile(path) + if err != nil { + return Account{}, fmt.Errorf("load local key for signer %q from %s: %w", alias, path, err) + } + if key.Type() != signer.ECDSA { + return Account{}, fmt.Errorf("signer %q is %q, but a secp256k1 (ecdsa) signer is required", alias, key.Type()) + } + + return AccountFromPrivateKeyBytes(key.PrivateKey()) +} + +func signerByAlias(cfg config.Config, alias string) (config.SignerConfig, bool) { + for _, s := range cfg.Signers { + if s.Alias == alias { + return s, true + } + } + return config.SignerConfig{}, false +} diff --git a/link/internal/deploy/artifact.go b/link/internal/deploy/artifact.go new file mode 100644 index 000000000..8dbdb7af6 --- /dev/null +++ b/link/internal/deploy/artifact.go @@ -0,0 +1,64 @@ +package deploy + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + _ "embed" +) + +// AccessManager and EVMIFTSendCallConstructor are embedded as forge JSON +// artifacts because upstream go-abigen ships no creation bytecode for them. The +// artifacts are slimmed to the two fields the loader reads (abi and +// bytecode.object); compiler metadata, AST, and deployed bytecode are stripped. +// Source: the e2e harness' Solidity IBC contracts build +// (e2e/internal/harness/environment/solidityibc/contracts/out); link keeps its own +// copy because it must not import e2e. + +//go:embed contracts/out/AccessManager.sol/AccessManager.json +var accessManagerArtifactJSON []byte + +//go:embed contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json +var sendCallConstructorArtifactJSON []byte + +type forgeArtifact struct { + ABI json.RawMessage `json:"abi"` + Bytecode struct { + Object string `json:"object"` + } `json:"bytecode"` +} + +func loadAccessManagerArtifact() (abi.ABI, []byte, error) { + return loadForgeArtifact("AccessManager", accessManagerArtifactJSON) +} + +func loadSendCallConstructorArtifact() (abi.ABI, []byte, error) { + return loadForgeArtifact("EVMIFTSendCallConstructor", sendCallConstructorArtifactJSON) +} + +func loadForgeArtifact(label string, raw []byte) (abi.ABI, []byte, error) { + parsed, bytecode, err := decodeForgeArtifact(raw) + if err != nil { + return abi.ABI{}, nil, fmt.Errorf("%s artifact: %w", label, err) + } + if len(bytecode) == 0 { + return abi.ABI{}, nil, fmt.Errorf("%s artifact has empty creation bytecode", label) + } + return parsed, bytecode, nil +} + +func decodeForgeArtifact(raw []byte) (abi.ABI, []byte, error) { + var artifact forgeArtifact + if err := json.Unmarshal(raw, &artifact); err != nil { + return abi.ABI{}, nil, fmt.Errorf("decode artifact: %w", err) + } + parsed, err := abi.JSON(strings.NewReader(string(artifact.ABI))) + if err != nil { + return abi.ABI{}, nil, fmt.Errorf("parse ABI: %w", err) + } + return parsed, common.FromHex(artifact.Bytecode.Object), nil +} diff --git a/link/internal/deploy/connect_anvil_test.go b/link/internal/deploy/connect_anvil_test.go new file mode 100644 index 000000000..63a52e4c6 --- /dev/null +++ b/link/internal/deploy/connect_anvil_test.go @@ -0,0 +1,328 @@ +package deploy + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ift" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +// anvilTestEnv gates the anvil-backed integration test. It spins up two docker +// containers, so it is skipped unless TEST_ANVIL is set. +const anvilTestEnv = "TEST_ANVIL" + +// anvilImage is the foundry image that ships anvil. Override with ANVIL_IMAGE. +const anvilImage = "ghcr.io/foundry-rs/foundry:v1.7.1" + +// anvilAccount0Key is anvil's first deterministic dev account private key +// (mnemonic "test test ... junk"). It is prefunded on every anvil chain. +const anvilAccount0Key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +func anvilImageRef() string { + if img := os.Getenv("ANVIL_IMAGE"); img != "" { + return img + } + return anvilImage +} + +// startAnvil launches one anvil container and returns its RPC URL. +func startAnvil(ctx context.Context, t *testing.T, chainID uint64) string { + t.Helper() + + req := testcontainers.ContainerRequest{ + Image: anvilImageRef(), + Entrypoint: []string{"anvil"}, + Cmd: []string{"--host", "0.0.0.0", "--port", "8545", "--chain-id", strconv.FormatUint(chainID, 10)}, + ExposedPorts: []string{"8545/tcp"}, + WaitingFor: wait.ForListeningPort("8545/tcp").WithStartupTimeout(60 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err, "start anvil container") + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(container); err != nil { + t.Logf("terminate anvil container: %v", err) + } + }) + + host, err := container.Host(ctx) + require.NoError(t, err) + port, err := container.MappedPort(ctx, "8545/tcp") + require.NoError(t, err) + return fmt.Sprintf("http://%s:%s", host, port.Port()) +} + +func TestConnectAcrossTwoAnvilChains(t *testing.T) { + if os.Getenv(anvilTestEnv) == "" { + t.Skipf("set %s to run the two-anvil integration test", anvilTestEnv) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + rpcA := startAnvil(ctx, t, 31337) + rpcB := startAnvil(ctx, t, 31338) + + // Write the deployer key file (anvil's prefunded account 0). + home := t.TempDir() + keyBytes, err := hex.DecodeString(anvilAccount0Key) + require.NoError(t, err) + deployerSigner, err := signer.NewLocalSecp256k1Signer(keyBytes) + require.NoError(t, err) + keyPath, err := signer.KeyFilePath(home, "deployer") + require.NoError(t, err) + require.NoError(t, deployerSigner.StoreToFile(keyPath)) + + // Two arbitrary attestor addresses (constructor args only; no funding needed). + att1, err := newTestAccount() + require.NoError(t, err) + att2, err := newTestAccount() + require.NoError(t, err) + + cfg := config.Config{ + Chains: []config.ChainConfig{ + {ChainID: "31337", EVM: &config.EVMChainConfig{RPC: rpcA}}, + {ChainID: "31338", EVM: &config.EVMChainConfig{RPC: rpcB}}, + }, + Signers: config.Signers{ + {Alias: "deployer", Type: config.SignerLocal, File: keyPath}, + }, + Relayer: config.RelayerConfig{ + Signer: "deployer", + Clients: []config.ClientConfig{ + { + Alias: "a-to-b", ClientID: "chainb-0", ChainID: "31337", + CounterpartyChainID: "31338", CounterpartyClientID: "chaina-0", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att1", Type: config.AttestorTypeRemote, GRPC: "x:1", EVMAddress: att1.Address().Hex()}, + }, + }, + }, + { + Alias: "b-to-a", ClientID: "chaina-0", ChainID: "31338", + CounterpartyChainID: "31337", CounterpartyClientID: "chainb-0", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att2", Type: config.AttestorTypeRemote, GRPC: "y:1", EVMAddress: att2.Address().Hex()}, + }, + }, + }, + }, + }, + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + result, err := Connect(ctx, cfg, "a-to-b", Options{SignerAlias: "deployer", Logger: logger}) + require.NoError(t, err) + require.Len(t, result.Chains, 2) + + byChain := map[string]ChainResult{} + for _, cr := range result.Chains { + byChain[cr.ChainID] = cr + } + local := byChain["31337"] + remote := byChain["31338"] + + require.NotEmpty(t, local.ICS26Router) + require.NotEmpty(t, remote.ICS26Router) + require.NotEmpty(t, local.GMP) + require.NotEmpty(t, local.IFT) + require.NotEmpty(t, local.SendCallConstructor) + require.NotNil(t, local.Client) + require.Equal(t, "chainb-0", local.Client.ClientID) + require.Equal(t, "chaina-0", remote.Client.ClientID) + + // On-chain assertions per side. + assertSide(ctx, t, rpcA, local, "chainb-0", "chaina-0", remote.IFT) + assertSide(ctx, t, rpcB, remote, "chaina-0", "chainb-0", local.IFT) + + // Rerun-after-complete: fold the deployed routers back into config (as + // --write-config would) and re-run connect. The instance attaches, but the + // already-registered GMP port makes the app phase fail loud and harmlessly + // with the documented message. The partial result still carries the durable, + // unchanged router addresses. + applyResultToConfig(&cfg, result) + second, err := Connect(ctx, cfg, "a-to-b", Options{SignerAlias: "deployer", Logger: logger}) + require.Error(t, err) + require.ErrorContains(t, err, "partial app state detected") + require.ErrorContains(t, err, "already fully wired, no action is needed") + require.NotNil(t, second, "partial result must be returned even on failure") + for _, cr := range second.Chains { + if cr.ICS26Router != "" { + require.Equal(t, byChain[cr.ChainID].ICS26Router, cr.ICS26Router, "router must be stable across re-runs") + } + } +} + +// TestConnectWrongChainIDFailsBeforeDeploy asserts a chainId that disagrees with +// the RPC's eth_chainId is rejected before any contract is deployed. +func TestConnectWrongChainIDFailsBeforeDeploy(t *testing.T) { + if os.Getenv(anvilTestEnv) == "" { + t.Skipf("set %s to run the two-anvil integration test", anvilTestEnv) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Anvil reports chain id 31337, but the config claims 99999 for that RPC. + rpcA := startAnvil(ctx, t, 31337) + rpcB := startAnvil(ctx, t, 31338) + + home := t.TempDir() + keyBytes, err := hex.DecodeString(anvilAccount0Key) + require.NoError(t, err) + deployerSigner, err := signer.NewLocalSecp256k1Signer(keyBytes) + require.NoError(t, err) + keyPath, err := signer.KeyFilePath(home, "deployer") + require.NoError(t, err) + require.NoError(t, deployerSigner.StoreToFile(keyPath)) + + att1, err := newTestAccount() + require.NoError(t, err) + att2, err := newTestAccount() + require.NoError(t, err) + + cfg := config.Config{ + Chains: []config.ChainConfig{ + {ChainID: "99999", EVM: &config.EVMChainConfig{RPC: rpcA}}, // wrong: RPC is 31337 + {ChainID: "31338", EVM: &config.EVMChainConfig{RPC: rpcB}}, + }, + Signers: config.Signers{{Alias: "deployer", Type: config.SignerLocal, File: keyPath}}, + Relayer: config.RelayerConfig{ + Signer: "deployer", + Clients: []config.ClientConfig{ + { + Alias: "a-to-b", ClientID: "chainb-0", ChainID: "99999", + CounterpartyChainID: "31338", CounterpartyClientID: "chaina-0", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att1", Type: config.AttestorTypeRemote, GRPC: "x:1", EVMAddress: att1.Address().Hex()}, + }, + }, + }, + { + Alias: "b-to-a", ClientID: "chaina-0", ChainID: "31338", + CounterpartyChainID: "99999", CounterpartyClientID: "chainb-0", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att2", Type: config.AttestorTypeRemote, GRPC: "y:1", EVMAddress: att2.Address().Hex()}, + }, + }, + }, + }, + }, + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + result, err := Connect(ctx, cfg, "a-to-b", Options{SignerAlias: "deployer", Logger: logger}) + require.Error(t, err) + require.ErrorContains(t, err, "chain identity mismatch") + // Nothing was deployed on the mismatched chain. + for _, cr := range result.Chains { + require.Empty(t, cr.ICS26Router, "no contract should be deployed when chain identity fails") + } +} + +// applyResultToConfig folds the deployed router addresses back into an in-memory +// config (chains[].evm.ics26Router and attestor.attestations[].evm.routerAddress), +// mirroring what --write-config persists to disk via PatchConfigFile. It is a test +// helper used to simulate a config already carrying the deployed routers. +func applyResultToConfig(cfg *config.Config, result *Result) { + if result == nil { + return + } + for _, chainResult := range result.Chains { + if chainResult.ICS26Router == "" { + continue + } + for i := range cfg.Chains { + chain := &cfg.Chains[i] + if chain.ChainID == chainResult.ChainID && chain.EVM != nil { + chain.EVM.ICS26Router = chainResult.ICS26Router + } + } + for i := range cfg.Attestor.Attestations { + att := &cfg.Attestor.Attestations[i] + if att.ChainID == chainResult.ChainID && att.EVM != nil { + att.EVM.RouterAddress = chainResult.ICS26Router + } + } + } +} + +func assertSide( + ctx context.Context, + t *testing.T, + rpc string, + cr ChainResult, + clientID, counterpartyClientID, counterpartyIFT string, +) { + t.Helper() + + client, err := ethclient.DialContext(ctx, rpc) + require.NoError(t, err) + defer client.Close() + + routerAddr := common.HexToAddress(cr.ICS26Router) + code, err := client.CodeAt(ctx, routerAddr, nil) + require.NoError(t, err) + require.NotEmpty(t, code, "router must have code") + + router, err := ics26router.NewContract(routerAddr, client) + require.NoError(t, err) + + authority, err := router.Authority(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(cr.AccessManager), authority) + + registered, err := router.GetClient(&bind.CallOpts{Context: ctx}, clientID) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, registered) + require.Equal(t, cr.Client.Address, registered.Hex()) + + counterparty, err := router.GetCounterparty(&bind.CallOpts{Context: ctx}, clientID) + require.NoError(t, err) + require.Equal(t, counterpartyClientID, counterparty.ClientId) + require.Len(t, counterparty.MerklePrefix, 1) + require.Empty(t, counterparty.MerklePrefix[0]) + + // IFT bridge is cross-wired to the counterparty IFT. + iftContract, err := ift.NewContract(common.HexToAddress(cr.IFT), client) + require.NoError(t, err) + bridge, err := iftContract.GetIFTBridge(&bind.CallOpts{Context: ctx}, clientID) + require.NoError(t, err) + require.Equal(t, + common.HexToAddress(counterpartyIFT), + common.HexToAddress(bridge.CounterpartyIFTAddress), + "IFT bridge must point at the counterparty IFT") + require.Equal(t, common.HexToAddress(cr.SendCallConstructor), bridge.IftSendCallConstructor) +} diff --git a/link/internal/deploy/contracts/out/AccessManager.sol/AccessManager.json b/link/internal/deploy/contracts/out/AccessManager.sol/AccessManager.json new file mode 100644 index 000000000..b09c1438d --- /dev/null +++ b/link/internal/deploy/contracts/out/AccessManager.sol/AccessManager.json @@ -0,0 +1 @@ +{"abi":[{"type":"constructor","inputs":[{"name":"initialAdmin","type":"address","internalType":"address"}],"stateMutability":"nonpayable"},{"type":"function","name":"ADMIN_ROLE","inputs":[],"outputs":[{"name":"","type":"uint64","internalType":"uint64"}],"stateMutability":"view"},{"type":"function","name":"PUBLIC_ROLE","inputs":[],"outputs":[{"name":"","type":"uint64","internalType":"uint64"}],"stateMutability":"view"},{"type":"function","name":"canCall","inputs":[{"name":"caller","type":"address","internalType":"address"},{"name":"target","type":"address","internalType":"address"},{"name":"selector","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"immediate","type":"bool","internalType":"bool"},{"name":"delay","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"cancel","inputs":[{"name":"caller","type":"address","internalType":"address"},{"name":"target","type":"address","internalType":"address"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"nonpayable"},{"type":"function","name":"consumeScheduledOp","inputs":[{"name":"caller","type":"address","internalType":"address"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"execute","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"payable"},{"type":"function","name":"expiration","inputs":[],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"getAccess","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"since","type":"uint48","internalType":"uint48"},{"name":"currentDelay","type":"uint32","internalType":"uint32"},{"name":"pendingDelay","type":"uint32","internalType":"uint32"},{"name":"effect","type":"uint48","internalType":"uint48"}],"stateMutability":"view"},{"type":"function","name":"getNonce","inputs":[{"name":"id","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"getRoleAdmin","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"}],"outputs":[{"name":"","type":"uint64","internalType":"uint64"}],"stateMutability":"view"},{"type":"function","name":"getRoleGrantDelay","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"}],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"getRoleGuardian","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"}],"outputs":[{"name":"","type":"uint64","internalType":"uint64"}],"stateMutability":"view"},{"type":"function","name":"getSchedule","inputs":[{"name":"id","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint48","internalType":"uint48"}],"stateMutability":"view"},{"type":"function","name":"getTargetAdminDelay","inputs":[{"name":"target","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"getTargetFunctionRole","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"selector","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"uint64","internalType":"uint64"}],"stateMutability":"view"},{"type":"function","name":"grantRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"account","type":"address","internalType":"address"},{"name":"executionDelay","type":"uint32","internalType":"uint32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"hasRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"isMember","type":"bool","internalType":"bool"},{"name":"executionDelay","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"hashOperation","inputs":[{"name":"caller","type":"address","internalType":"address"},{"name":"target","type":"address","internalType":"address"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"isTargetClosed","inputs":[{"name":"target","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"labelRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"label","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"minSetback","inputs":[],"outputs":[{"name":"","type":"uint32","internalType":"uint32"}],"stateMutability":"view"},{"type":"function","name":"multicall","inputs":[{"name":"data","type":"bytes[]","internalType":"bytes[]"}],"outputs":[{"name":"results","type":"bytes[]","internalType":"bytes[]"}],"stateMutability":"nonpayable"},{"type":"function","name":"renounceRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"callerConfirmation","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"schedule","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"data","type":"bytes","internalType":"bytes"},{"name":"when","type":"uint48","internalType":"uint48"}],"outputs":[{"name":"operationId","type":"bytes32","internalType":"bytes32"},{"name":"nonce","type":"uint32","internalType":"uint32"}],"stateMutability":"nonpayable"},{"type":"function","name":"setGrantDelay","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"newDelay","type":"uint32","internalType":"uint32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setRoleAdmin","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"admin","type":"uint64","internalType":"uint64"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setRoleGuardian","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"},{"name":"guardian","type":"uint64","internalType":"uint64"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setTargetAdminDelay","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"newDelay","type":"uint32","internalType":"uint32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setTargetClosed","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"closed","type":"bool","internalType":"bool"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setTargetFunctionRole","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"selectors","type":"bytes4[]","internalType":"bytes4[]"},{"name":"roleId","type":"uint64","internalType":"uint64"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"updateAuthority","inputs":[{"name":"target","type":"address","internalType":"address"},{"name":"newAuthority","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"OperationCanceled","inputs":[{"name":"operationId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"nonce","type":"uint32","indexed":true,"internalType":"uint32"}],"anonymous":false},{"type":"event","name":"OperationExecuted","inputs":[{"name":"operationId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"nonce","type":"uint32","indexed":true,"internalType":"uint32"}],"anonymous":false},{"type":"event","name":"OperationScheduled","inputs":[{"name":"operationId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"nonce","type":"uint32","indexed":true,"internalType":"uint32"},{"name":"schedule","type":"uint48","indexed":false,"internalType":"uint48"},{"name":"caller","type":"address","indexed":false,"internalType":"address"},{"name":"target","type":"address","indexed":false,"internalType":"address"},{"name":"data","type":"bytes","indexed":false,"internalType":"bytes"}],"anonymous":false},{"type":"event","name":"RoleAdminChanged","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"admin","type":"uint64","indexed":true,"internalType":"uint64"}],"anonymous":false},{"type":"event","name":"RoleGrantDelayChanged","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"delay","type":"uint32","indexed":false,"internalType":"uint32"},{"name":"since","type":"uint48","indexed":false,"internalType":"uint48"}],"anonymous":false},{"type":"event","name":"RoleGranted","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"delay","type":"uint32","indexed":false,"internalType":"uint32"},{"name":"since","type":"uint48","indexed":false,"internalType":"uint48"},{"name":"newMember","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"RoleGuardianChanged","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"guardian","type":"uint64","indexed":true,"internalType":"uint64"}],"anonymous":false},{"type":"event","name":"RoleLabel","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"label","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"RoleRevoked","inputs":[{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"},{"name":"account","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"TargetAdminDelayUpdated","inputs":[{"name":"target","type":"address","indexed":true,"internalType":"address"},{"name":"delay","type":"uint32","indexed":false,"internalType":"uint32"},{"name":"since","type":"uint48","indexed":false,"internalType":"uint48"}],"anonymous":false},{"type":"event","name":"TargetClosed","inputs":[{"name":"target","type":"address","indexed":true,"internalType":"address"},{"name":"closed","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"TargetFunctionRoleUpdated","inputs":[{"name":"target","type":"address","indexed":true,"internalType":"address"},{"name":"selector","type":"bytes4","indexed":false,"internalType":"bytes4"},{"name":"roleId","type":"uint64","indexed":true,"internalType":"uint64"}],"anonymous":false},{"type":"error","name":"AccessManagerAlreadyScheduled","inputs":[{"name":"operationId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AccessManagerBadConfirmation","inputs":[]},{"type":"error","name":"AccessManagerExpired","inputs":[{"name":"operationId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AccessManagerInvalidInitialAdmin","inputs":[{"name":"initialAdmin","type":"address","internalType":"address"}]},{"type":"error","name":"AccessManagerLockedRole","inputs":[{"name":"roleId","type":"uint64","internalType":"uint64"}]},{"type":"error","name":"AccessManagerNotReady","inputs":[{"name":"operationId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AccessManagerNotScheduled","inputs":[{"name":"operationId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AccessManagerUnauthorizedAccount","inputs":[{"name":"msgsender","type":"address","internalType":"address"},{"name":"roleId","type":"uint64","internalType":"uint64"}]},{"type":"error","name":"AccessManagerUnauthorizedCall","inputs":[{"name":"caller","type":"address","internalType":"address"},{"name":"target","type":"address","internalType":"address"},{"name":"selector","type":"bytes4","internalType":"bytes4"}]},{"type":"error","name":"AccessManagerUnauthorizedCancel","inputs":[{"name":"msgsender","type":"address","internalType":"address"},{"name":"caller","type":"address","internalType":"address"},{"name":"target","type":"address","internalType":"address"},{"name":"selector","type":"bytes4","internalType":"bytes4"}]},{"type":"error","name":"AccessManagerUnauthorizedConsume","inputs":[{"name":"target","type":"address","internalType":"address"}]},{"type":"error","name":"AddressEmptyCode","inputs":[{"name":"target","type":"address","internalType":"address"}]},{"type":"error","name":"FailedCall","inputs":[]},{"type":"error","name":"InsufficientBalance","inputs":[{"name":"balance","type":"uint256","internalType":"uint256"},{"name":"needed","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"SafeCastOverflowedUintDowncast","inputs":[{"name":"bits","type":"uint8","internalType":"uint8"},{"name":"value","type":"uint256","internalType":"uint256"}]}],"bytecode":{"object":"0x6080604052346102b757604051601f6130be38819003918201601f19168301916001600160401b0383118484101761015a578084926020946040528339810103126102b757516001600160a01b038116908190036102b75780156102a4575f8181525f51602061307e5f395f51905f52602052604090205465ffffffffffff161580156101825765ffffffffffff610096426102bb565b1665ffffffffffff811161016e578060405191604083019383851060018060401b0386111761015a5760409485529183525f60208085018281528783525f51602061307e5f395f51905f529091529481209351845495516001600160a01b031990961665ffffffffffff919091161760309590951b600160301b600160a01b0316949094179092555f51602061309e5f395f51905f52916060915b65ffffffffffff604051928684521660208301526040820152a3604051612d9390816102eb8239f35b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b5f8281525f51602061307e5f395f51905f526020526040902054906101a6426102bb565b63ffffffff8360301c169265ffffffffffff808260701c1692168211155f146102925750505b63ffffffff8216801561028b5763ffffffff811161016e575b65ffffffffffff63ffffffff6101fa426102bb565b921691160165ffffffffffff811161016e575f8481525f51602061307e5f395f51905f52602090815260408083208054600160301b600160a01b0319169185901b6dffffffffffff0000000000000000169690921b67ffffffff00000000169590951760301b600160301b600160a01b0316949094179093555f51602061309e5f395f51905f529160609190610131565b505f6101e5565b63ffffffff9060501c169250506101cc565b630409d6d160e11b5f525f60045260245ffd5b5f80fd5b65ffffffffffff81116102d35765ffffffffffff1690565b6306dfcc6560e41b5f52603060045260245260445ffdfe60806040526004361015610011575f80fd5b5f5f3560e01c806308d6122d14611a305780630b0a93ba146119e957806312be8727146119c6578063167bd3951461190957806318ff183c146118485780631cff79cd1461169757806325c471a01461128c5780633078f1141461123157806330cae187146111715780633adc277a146111425780633ca7c02a1461111f5780634136a33c146110ec5780634665096d146110ce5780634c1da1e21461109c5780635296295214610fc8578063530dd45614610f715780636d5115bd14610eeb57806375b238fc14610ecf578063853551b814610dfa57806394c7d7ee14610c98578063a166aa8914610c42578063a64d95ce14610afb578063abd9bd2a14610ad6578063ac9650d8146108eb578063b70096131461088e578063b7d2b1621461085b578063cc1b6c811461083d578063d1f856ee146107f8578063d22b5989146106f5578063d6bb62c6146104a7578063f801a698146101f25763fe0776f51461017a575f80fd5b346101ef5760406003193601126101ef57610193611bc7565b61019b611b73565b903373ffffffffffffffffffffffffffffffffffffffff8316036101c757906101c3916124da565b5080f35b6004837f5f159e63000000000000000000000000000000000000000000000000000000008152fd5b80fd5b50346101ef5760606003193601126101ef5761020c611b50565b9060243567ffffffffffffffff81116104a35761022d903690600401611bf5565b919060443565ffffffffffff811680910361049f5761024e848387336121a5565b905061026a63ffffffff61026142612d3e565b921680926120a1565b90158015610484575b61040e579065ffffffffffff809216908180821191180218169061029984828733611e6a565b93848452600260205265ffffffffffff60408520541680151590816103fd575b506103d15760409584936103c287947f82a2da5dee54ea8021c6545b4444620291e07ee83be6dd57edb175062715f3b494868b99526002602052600163ffffffff8a8a205460301c160163ffffffff81169989898c9b52600260205281812065ffffffffffff88167fffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000825416179055898152600260205220907fffffffffffffffffffffffffffffffffffffffffffff00000000ffffffffffff69ffffffff00000000000083549260301b16911617905573ffffffffffffffffffffffffffffffffffffffff8b519586958652336020870152168b850152608060608501526080840191611e4a565b0390a382519182526020820152f35b602484867f813e9459000000000000000000000000000000000000000000000000000000008252600452fd5b6104079150612484565b155f6102b9565b6064847fffffffff000000000000000000000000000000000000000000000000000000008873ffffffffffffffffffffffffffffffffffffffff6104528a896121f9565b917f81c6f24b000000000000000000000000000000000000000000000000000000008552336004521660245216604452fd5b508115158015610273575065ffffffffffff81168210610273565b8280fd5b5080fd5b50346101ef576104cf906104ba36611c87565b6104c781839497936121f9565b928685611e6a565b91828452600260205265ffffffffffff604085205416155f1461051857602484847f60a299b0000000000000000000000000000000000000000000000000000000008252600452fd5b73ffffffffffffffffffffffffffffffffffffffff16903382036105b0575b50506020925080825260028352604082207fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000081541690558082526002835263ffffffff604083205460301c1680917fbd9ac67a6e2f6463b80927326310338bcbb4bdb7936ce1365ea3e01067e7b9f76040519480a38152f35b65ffffffffffff946105c2335f611d7d565b505096169586151596876106c1575b509073ffffffffffffffffffffffffffffffffffffffff91501694858552846020527fffffffff00000000000000000000000000000000000000000000000000000000604086209216918286526020526106653361066067ffffffffffffffff60408920541667ffffffffffffffff165f52600160205267ffffffffffffffff600160405f20015460401c1690565b61204a565b50901590816106b8575b5015610537576084925084604051927f3fe2751c000000000000000000000000000000000000000000000000000000008452336004850152602484015260448301526064820152fd5b9050155f61066f565b73ffffffffffffffffffffffffffffffffffffffff9291975065ffffffffffff6106ea42612d3e565b1610159690916105d1565b50346101ef5760406003193601126101ef5761070f611b50565b7fa56b76017453f399ec2327ba00375dbfb1fd070ff854341ad6191e6a2e2de19c73ffffffffffffffffffffffffffffffffffffffff61074d611c74565b926107566120ec565b169182845283602052610780816dffffffffffffffffffffffffffff600160408820015416612ca6565b9190848652856020526dffffffffffffffffffffffffffff6001604088200191167fffffffffffffffffffffffffffffffffffff00000000000000000000000000008254161790556107f26040519283928390929165ffffffffffff60209163ffffffff604085019616845216910152565b0390a280f35b50346101ef5760406003193601126101ef57610823610815611bc7565b61081d611b73565b9061204a565b60408051921515835263ffffffff91909116602083015290f35b50346101ef57806003193601126101ef576020604051620697808152f35b50346101ef5760406003193601126101ef576101c3610878611bc7565b610880611b73565b906108896120ec565b6124da565b50346101ef5760606003193601126101ef576108a8611b50565b6108b0611b73565b604435917fffffffff00000000000000000000000000000000000000000000000000000000831683036108e7576108239350611f1b565b8380fd5b50346101ef5760206003193601126101ef5760043567ffffffffffffffff81116104a35761091d903690600401611b96565b90602060405161092d8282611d2d565b84815281810191601f19810136843761094585611ec2565b936109536040519586611d2d565b858552601f1961096287611ec2565b01875b818110610ac757505086907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301915b87811015610a31578060051b82013583811215610a2d5782019081359167ffffffffffffffff8311610a295785018236038113610a295782610a07610a0d928d898c6001986040519687958487013784018281018481528e519283915e010190815203601f198101835282611d2d565b306124b8565b610a17828a611eda565b52610a228189611eda565b5001610997565b8a80fd5b8980fd5b88848860405191808301818452825180915260408401918060408360051b870101940192865b838810610a645786860387f35b9091929394838080837fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc08b60019603018752601f19601f838c518051918291828752018686015e8885828601015201160101970193019701969093929193610a57565b60608782018501528301610965565b50346101ef576020610af3610aea36611c87565b92919091611e6a565b604051908152f35b50346101ef5760406003193601126101ef57610b15611bc7565b67ffffffffffffffff610b26611c74565b91610b2f6120ec565b169067ffffffffffffffff8214610c16577ffeb69018ee8b8fd50ea86348f1267d07673379f72cffdeccec63853ee8ce8b48908284526001602052610b8e816dffffffffffffffffffffffffffff600160408820015460801c16612ca6565b9190848652600160205260016040872001907fffff0000000000000000000000000000ffffffffffffffffffffffffffffffff7dffffffffffffffffffffffffffff0000000000000000000000000000000083549260801b1691161790556107f26040519283928390929165ffffffffffff60209163ffffffff604085019616845216910152565b602483837f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b50346101ef5760206003193601126101ef576020610c8e610c61611b50565b73ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b6040519015158152f35b50346101ef57610ca736611c23565b916040517f8fb36037000000000000000000000000000000000000000000000000000000008152602081600481335afa908115610def578591610d70575b507fffffffff000000000000000000000000000000000000000000000000000000007f8fb3603700000000000000000000000000000000000000000000000000000000911603610d445791610d3f916101c3933390611e6a565b612227565b6024847f320ff74800000000000000000000000000000000000000000000000000000000815233600452fd5b90506020813d602011610de7575b81610d8b60209383611d2d565b81010312610de357517fffffffff0000000000000000000000000000000000000000000000000000000081168103610de3577fffffffff00000000000000000000000000000000000000000000000000000000610ce5565b8480fd5b3d9150610d7e565b6040513d87823e3d90fd5b50346101ef5760406003193601126101ef57610e14611bc7565b6024359067ffffffffffffffff821161049f57610e3e67ffffffffffffffff923690600401611bf5565b929091610e496120ec565b169182158015610ebe575b610e9257907f1256f5b5ecb89caec12db449738f2fbcd1ba5806cf38f35413f4e5c15bf6a450916107f2604051928392602084526020840191611e4a565b602484847f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b5067ffffffffffffffff8314610e54565b50346101ef57806003193601126101ef57602090604051908152f35b50346101ef5760406003193601126101ef57610f05611b50565b602435907fffffffff00000000000000000000000000000000000000000000000000000000821680920361049f5760209267ffffffffffffffff9273ffffffffffffffffffffffffffffffffffffffff6040931682528185528282209082528452205416604051908152f35b50346101ef5760206003193601126101ef576020610fb6610f90611bc7565b67ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b67ffffffffffffffff60405191168152f35b50346101ef5760406003193601126101ef57610fe2611bc7565b67ffffffffffffffff610ff3611bde565b91610ffc6120ec565b16908115801561108b575b610c165767ffffffffffffffff9082845260016020526001604085200180547fffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff6fffffffffffffffff00000000000000008460401b16911617905516907f7a8059630b897b5de4c08ade69f8b90c3ead1f8596d62d10b6c4d14a0afb4ae28380a380f35b5067ffffffffffffffff8214611007565b50346101ef5760206003193601126101ef5760206110c06110bb611b50565b611e0e565b63ffffffff60405191168152f35b50346101ef57806003193601126101ef57602060405162093a808152f35b50346101ef5760206003193601126101ef5763ffffffff6040602092600435815260028452205460301c16604051908152f35b50346101ef57806003193601126101ef57602060405167ffffffffffffffff8152f35b50346101ef5760206003193601126101ef576020611161600435611de4565b65ffffffffffff60405191168152f35b50346101ef5760406003193601126101ef5761118b611bc7565b67ffffffffffffffff61119c611bde565b916111a56120ec565b169081158015611220575b610c165767ffffffffffffffff908284526001602052600160408520018282167fffffffffffffffffffffffffffffffffffffffffffffffff000000000000000082541617905516907f1fd6dd7631312dfac2205b52913f99de03b4d7e381d5d27d3dbfe0713e6e63408380a380f35b5067ffffffffffffffff82146111b0565b50346101ef5760406003193601126101ef57608063ffffffff65ffffffffffff8161126b61125d611bc7565b611265611b73565b90611d7d565b93929590918560405197168752166020860152166040840152166060820152f35b50346101ef5760606003193601126101ef576112a6611bc7565b6112ae611b73565b906044359163ffffffff83168093036108e7576112c96120ec565b67ffffffffffffffff6112db83611cf4565b92169167ffffffffffffffff831461166b5782855260016020526040852073ffffffffffffffffffffffffffffffffffffffff83165f5260205265ffffffffffff60405f2054161590815f14611493576113459063ffffffff61133d42612d3e565b9116906120a1565b6040516040810181811067ffffffffffffffff821117611466579265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff9361144f7ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf97946dffffffffffffffffffffffffffff8b60408e60609b82528787168552602085019283528d81526001602052208989165f52602052858060405f20945116167fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000084541617835551167fffffffffffffffffffffffff0000000000000000000000000000ffffffffffff73ffffffffffffffffffffffffffff00000000000083549260301b169116179055565b60405198895216602088015260408701521693a380f35b6024887f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b5082855260016020526040852073ffffffffffffffffffffffffffffffffffffffff83165f526020526114dc6dffffffffffffffffffffffffffff60405f205460301c16612447565b50508463ffffffff82168181115f1461160d570363ffffffff81116115e0579260609265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff936115db67ffffffff0000000061156263ffffffff7ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf9a5b1661155d42612d3e565b6120a1565b9260201b166dffffffffffff00000000000000008360401b16178a17898c52600160205260408c208787165f5260205260405f20907fffffffffffffffffffffffff0000000000000000000000000000ffffffffffff73ffffffffffffffffffffffffffff00000000000083549260301b169116179055565b61144f565b6024877f4e487b710000000000000000000000000000000000000000000000000000000081526011600452fd5b50507ff98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf9260609265ffffffffffff73ffffffffffffffffffffffffffffffffffffffff936115db67ffffffff0000000061156263ffffffff8d611553565b602485847f1871a90c000000000000000000000000000000000000000000000000000000008252600452fd5b506116a136611c23565b90916116af828483336121a5565b9390158061183a575b6117f6576116c883828433611e6a565b63ffffffff869516158015906117dd575b6117cb575b50600354926117376116f082846121f9565b849073ffffffffffffffffffffffffffffffffffffffff7fffffffff0000000000000000000000000000000000000000000000000000000092165f521660205260405f2090565b60035567ffffffffffffffff811161179e5760405191611761601f8301601f191660200184611d2d565b818352368282011161179a5795602082849382998361178898970137830101523491612358565b5060035563ffffffff60405191168152f35b8680fd5b6024867f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b6117d6919450612227565b925f6116de565b5065ffffffffffff6117ee82611de4565b1615156116d9565b849173ffffffffffffffffffffffffffffffffffffffff6104526064957fffffffff00000000000000000000000000000000000000000000000000000000946121f9565b5063ffffffff8416156116b8565b503461190557604060031936011261190557611862611b50565b73ffffffffffffffffffffffffffffffffffffffff61187f611b73565b916118886120ec565b1690813b156119055773ffffffffffffffffffffffffffffffffffffffff60245f928360405195869485937f7a9e5e4b0000000000000000000000000000000000000000000000000000000085521660048401525af180156118fa576118ec575080f35b6118f891505f90611d2d565b005b6040513d5f823e3d90fd5b5f80fd5b3461190557604060031936011261190557611922611b50565b6024359081151580920361190557602073ffffffffffffffffffffffffffffffffffffffff7f90d4e7bb7e5d933792b3562e1741306f8be94837e1348dacef9b6f1df56eb138926119716120ec565b1692835f525f8252600160405f200180547fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff6eff00000000000000000000000000008460701b169116179055604051908152a2005b346119055760206003193601126119055760206110c06119e4611bc7565b611cf4565b34611905576020600319360112611905576020610fb6611a07611bc7565b67ffffffffffffffff165f52600160205267ffffffffffffffff600160405f20015460401c1690565b3461190557606060031936011261190557611a49611b50565b60243567ffffffffffffffff811161190557611a69903690600401611b96565b91906044359067ffffffffffffffff821680920361190557611a8c9392936120ec565b73ffffffffffffffffffffffffffffffffffffffff5f9416935b838110156118f8578060051b820135907fffffffff0000000000000000000000000000000000000000000000000000000082168092036119055783867f9ea6790c7dadfd01c9f8b9762b3682607af2c7e79e05a9f9fdf5580dde9491516020600195835f525f825260405f20815f52825260405f20857fffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000825416179055604051908152a301611aa6565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361190557565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361190557565b9181601f840112156119055782359167ffffffffffffffff8311611905576020808501948460051b01011161190557565b6004359067ffffffffffffffff8216820361190557565b6024359067ffffffffffffffff8216820361190557565b9181601f840112156119055782359167ffffffffffffffff8311611905576020838186019501011161190557565b9060406003198301126119055760043573ffffffffffffffffffffffffffffffffffffffff8116810361190557916024359067ffffffffffffffff821161190557611c7091600401611bf5565b9091565b6024359063ffffffff8216820361190557565b60606003198201126119055760043573ffffffffffffffffffffffffffffffffffffffff81168103611905579160243573ffffffffffffffffffffffffffffffffffffffff8116810361190557916044359067ffffffffffffffff821161190557611c7091600401611bf5565b67ffffffffffffffff165f526001602052611d286dffffffffffffffffffffffffffff600160405f20015460801c16612447565b505090565b90601f601f19910116810190811067ffffffffffffffff821117611d5057604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b9067ffffffffffffffff65ffffffffffff9392165f52600160205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f5260205260405f205490611dda6dffffffffffffffffffffffffffff8360301c16612447565b9490931693909291565b5f52600260205265ffffffffffff60405f205416611e0181612484565b15611e0b57505f90565b90565b73ffffffffffffffffffffffffffffffffffffffff165f525f602052611d286dffffffffffffffffffffffffffff600160405f20015416612447565b601f8260209493601f1993818652868601375f8582860101520116010190565b9290611eae73ffffffffffffffffffffffffffffffffffffffff93611ebc936040519586948160208701991689521660408501526060808501526080840191611e4a565b03601f198101835282611d2d565b51902090565b67ffffffffffffffff8111611d505760051b60200190565b8051821015611eee5760209160051b010190565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b919091611f4f8373ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b15611f5d575050505f905f90565b73ffffffffffffffffffffffffffffffffffffffff81163003611fcf5750611fc990600354929073ffffffffffffffffffffffffffffffffffffffff7fffffffff0000000000000000000000000000000000000000000000000000000092165f521660205260405f2090565b14905f90565b9073ffffffffffffffffffffffffffffffffffffffff61203093165f525f6020527fffffffff0000000000000000000000000000000000000000000000000000000060405f2091165f5260205267ffffffffffffffff60405f20541661204a565b9190156120435763ffffffff8216159190565b5f91508190565b67ffffffffffffffff818116036120645750506001905f90565b65ffffffffffff929161207691611d7d565b50509216801515908161208857509190565b905065ffffffffffff61209a42612d3e565b1610159190565b9065ffffffffffff8091169116019065ffffffffffff82116120bf57565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b6120f636336125bb565b90156120ff5750565b63ffffffff1661214e5767ffffffffffffffff61211b36612721565b5090507ff07e038f000000000000000000000000000000000000000000000000000000005f52336004521660245260445ffd5b6121a2604051602081019033825230604082015260608082015261219a81602060808201368152365f838301375f823683010152601f19601f360116010103601f198101835282611d2d565b519020612227565b50565b9092919073ffffffffffffffffffffffffffffffffffffffff841630036121d057611c7093506126ad565b91929060048410156121e657505050505f905f90565b611c70936121f3916121f9565b91611f1b565b9060041161190557357fffffffff000000000000000000000000000000000000000000000000000000001690565b5f81815260026020526040902054909190603081901c63ffffffff169065ffffffffffff168061227d57837f60a299b0000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b65ffffffffffff61228d42612d3e565b168111156122c157837f18cb6b7a000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b6122ce9093919293612484565b61232d578190805f52600260205260405f207fffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000081541690557f76a2a46953689d4861a5d3f6ed883ad7e6af674a21f8e162707159fc9dde614d5f80a390565b7f78a5d6e4000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b9180471061241857815f92916020849351920190855af18080612405575b15612385575050611e0b612c8d565b156123cc5773ffffffffffffffffffffffffffffffffffffffff907f9996b315000000000000000000000000000000000000000000000000000000005f521660045260245ffd5b3d156123dd576040513d5f823e3d90fd5b7fd6bda275000000000000000000000000000000000000000000000000000000005f5260045ffd5b503d1515806123765750813b1515612376565b477fcf479181000000000000000000000000000000000000000000000000000000005f5260045260245260445ffd5b61245042612d3e565b63ffffffff82169165ffffffffffff604082901c811692168211612478575090915f91508190565b60201c63ffffffff1692565b65ffffffffffff62093a8091160165ffffffffffff81116120bf5765ffffffffffff806124b042612d3e565b169116111590565b905f8091602081519101845af480806124055715612385575050611e0b612c8d565b67ffffffffffffffff169067ffffffffffffffff821461258f57815f52600160205260405f2073ffffffffffffffffffffffffffffffffffffffff82165f5260205265ffffffffffff60405f205416156125895773ffffffffffffffffffffffffffffffffffffffff90825f52600160205260405f208282165f526020525f604081205516907ff229baa593af28c41b1d16b748cd7688f0c83aaf92d4be41c44005defe84c1665f80a3600190565b50505f90565b507f1871a90c000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b9060048110612624573073ffffffffffffffffffffffffffffffffffffffff83161461266c576125eb905f6129eb565b9290911580612635575b61262c576126029161204a565b90156126245763ffffffff808093169116908180821191180218169081159190565b50505f905f90565b5050505f905f90565b506126673073ffffffffffffffffffffffffffffffffffffffff165f525f60205260ff600160405f20015460701c1690565b6125f5565b905060041161190557600354305f90815280357fffffffff000000000000000000000000000000000000000000000000000000001660205260409020611fc9565b91906004821061262c573073ffffffffffffffffffffffffffffffffffffffff8416146126de57906125eb916129eb565b6126e892506121f9565b600354305f9081527fffffffff000000000000000000000000000000000000000000000000000000009092166020526040909120611fc9565b5f90600481106129e15780600411611905577fffffffff000000000000000000000000000000000000000000000000000000005f3516907f853551b800000000000000000000000000000000000000000000000000000000821480156129b8575b801561298f575b8015612966575b801561293d575b612931577f18ff183c0000000000000000000000000000000000000000000000000000000082148015612908575b80156128df575b6128a3577f25c471a0000000000000000000000000000000000000000000000000000000008214801561287a575b6128265750308252816020526040822090825260205267ffffffffffffffff6040822054169181929190565b90506024116101ef57806101ef575060043567ffffffffffffffff81168103611905576128739067ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b6001915f90565b507fb7d2b1620000000000000000000000000000000000000000000000000000000082146127fa565b9150506024116119055760043573ffffffffffffffffffffffffffffffffffffffff8116809103611905576128d790611e0e565b6001915f9190565b507f08d6122d0000000000000000000000000000000000000000000000000000000082146127cc565b507f167bd3950000000000000000000000000000000000000000000000000000000082146127c5565b5050506001905f905f90565b507fd22b5989000000000000000000000000000000000000000000000000000000008214612797565b507fa64d95ce000000000000000000000000000000000000000000000000000000008214612790565b507f52962952000000000000000000000000000000000000000000000000000000008214612789565b507f30cae187000000000000000000000000000000000000000000000000000000008214612782565b50505f905f905f90565b600482106129e1577fffffffff00000000000000000000000000000000000000000000000000000000612a1e83836121f9565b16917f853551b80000000000000000000000000000000000000000000000000000000083148015612c64575b8015612c3b575b8015612c12575b8015612be9575b612931577f18ff183c0000000000000000000000000000000000000000000000000000000083148015612bc0575b8015612b97575b612b62577f25c471a00000000000000000000000000000000000000000000000000000000083148015612b39575b612af0575050305f525f60205260405f20905f5260205267ffffffffffffffff60405f205416905f91905f90565b909150602411611905576004013567ffffffffffffffff81168103611905576128739067ffffffffffffffff165f52600160205267ffffffffffffffff600160405f2001541690565b507fb7d2b162000000000000000000000000000000000000000000000000000000008314612ac2565b909150602411611905576004013573ffffffffffffffffffffffffffffffffffffffff8116809103611905576128d790611e0e565b507f08d6122d000000000000000000000000000000000000000000000000000000008314612a94565b507f167bd395000000000000000000000000000000000000000000000000000000008314612a8d565b507fd22b5989000000000000000000000000000000000000000000000000000000008314612a5f565b507fa64d95ce000000000000000000000000000000000000000000000000000000008314612a58565b507f52962952000000000000000000000000000000000000000000000000000000008314612a51565b507f30cae187000000000000000000000000000000000000000000000000000000008314612a4a565b604051903d82523d5f602084013e60203d830101604052565b612cb763ffffffff91939293612447565b505092168063ffffffff84168181115f14612d24570363ffffffff81116120bf57612d0563ffffffff8067ffffffff00000000935b1680620697801181620697801802181661155d42612d3e565b9360201b166dffffffffffff00000000000000008460401b1617179190565b505067ffffffff00000000612d0563ffffffff805f612cec565b65ffffffffffff8111612d565765ffffffffffff1690565b7f6dfcc650000000000000000000000000000000000000000000000000000000005f52603060045260245260445ffdfea164736f6c634300081c000aa6eef7e35abe7026729641147f7915573c7e97b47efa546f5f6e3230263bcb49f98448b987f1428e0e230e1f3c6e2ce15b5693eaf31827fbd0b1ec4b424ae7cf"}} diff --git a/link/internal/deploy/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json b/link/internal/deploy/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json new file mode 100644 index 000000000..43bc1e027 --- /dev/null +++ b/link/internal/deploy/contracts/out/EVMIFTSendCallConstructor.sol/EVMIFTSendCallConstructor.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"constructMintCall","inputs":[{"name":"receiver","type":"string","internalType":"string"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bytes","internalType":"bytes"}],"stateMutability":"pure"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"error","name":"EVMIFTInvalidReceiver","inputs":[{"name":"receiver","type":"string","internalType":"string"}]}],"bytecode":{"object":"0x608080604052346015576105f0908161001a8239f35b5f80fdfe6080806040526004361015610012575f80fd5b5f3560e01c90816301ffc9a7146101f657506356d981a714610032575f80fd5b346101f25760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f25760043567ffffffffffffffff81116101f257366023820112156101f257806004013567ffffffffffffffff81116101f257602482019160248236920101116101f2577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f820116916100f76040516100dd60208601826102b2565b838152838360208301375f60208583010152805190610320565b9390156101a657836040805173ffffffffffffffffffffffffffffffffffffffff60208201937f0a7244e700000000000000000000000000000000000000000000000000000000855216602482015260243560448201526044815261015d6064826102b2565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8351948593602085525180918160208701528686015e5f85828601015201168101030190f35b906044915f83856040519687957fddd8c4b60000000000000000000000000000000000000000000000000000000087526020600488015281602488015283870137840101528101030190fd5b5f80fd5b346101f25760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101f257600435907fffffffff0000000000000000000000000000000000000000000000000000000082168092036101f257817f56d981a70000000000000000000000000000000000000000000000000000000060209314908115610288575b5015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501483610281565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176102f357604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b805182118015610409575b6103855760018211806103ba575b158015908160011b918204600214171561038d576028018060281161038d5782036103855773ffffffffffffffffffffffffffffffffffffffff92915f61037f92610410565b90921690565b50505f905f90565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b507f30780000000000000000000000000000000000000000000000000000000000007fffff00000000000000000000000000000000000000000000000000000000000060208301511614610339565b505f61032b565b9290926001840180851161038d578311806104c6575b15938415948560011b958604600214171561038d575f94810180911161038d579192905b81831061045a5750505060019190565b9092919360ff6104917fff000000000000000000000000000000000000000000000000000000000000006020888601015116610517565b16600f81116104bb578160041b918083046010149015171561038d5760019101940191929061044a565b505f94508493505050565b507f30780000000000000000000000000000000000000000000000000000000000007fffff000000000000000000000000000000000000000000000000000000000000602086840101511614610426565b60f81c602f8111806105d9575b15610551577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00160ff1690565b60608111806105cf575b15610588577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa90160ff1690565b60408111806105c5575b156105bf577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc90160ff1690565b5060ff90565b5060478110610592565b506067811061055b565b50603a811061052456fea164736f6c634300081c000a"}} diff --git a/link/internal/deploy/deploy.go b/link/internal/deploy/deploy.go new file mode 100644 index 000000000..ec0357188 --- /dev/null +++ b/link/internal/deploy/deploy.go @@ -0,0 +1,472 @@ +package deploy + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "net/url" + "sort" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cosmos/ibc/link/internal/config" +) + +// Options configures a deploy/connect run. +type Options struct { + // SignerAlias is the deployer signer alias. It must be a local secp256k1 + // signer. Defaults to relayer.signer when empty. + SignerAlias string + // DryRun resolves everything and prints the planned step sequence without + // dialing chains or broadcasting any transaction. + DryRun bool + // Logger receives progress logs (to stderr). Defaults to slog.Default(). + Logger *slog.Logger +} + +// ClientRes is the deployed/attached light client for one chain. +type ClientRes struct { + ClientID string `json:"clientId"` + Address string `json:"address"` +} + +// ChainResult reports the deployed/known contract addresses for one chain. +type ChainResult struct { + ChainID string `json:"chainId"` + AccessManager string `json:"accessManager,omitempty"` + ICS26Router string `json:"ics26Router,omitempty"` + Client *ClientRes `json:"client,omitempty"` + GMP string `json:"gmp,omitempty"` + IFT string `json:"ift,omitempty"` + SendCallConstructor string `json:"sendCallConstructor,omitempty"` + // Skipped lists steps that were skipped because the target already existed. + Skipped []string `json:"skipped,omitempty"` +} + +// PlanStep is one planned action in a --dry-run plan. +type PlanStep struct { + ChainID string `json:"chainId"` + Action string `json:"action"` + Detail string `json:"detail,omitempty"` +} + +// Result is the machine-readable outcome emitted on stdout. +type Result struct { + Mode string `json:"mode"` + DryRun bool `json:"dryRun"` + Chains []ChainResult `json:"chains,omitempty"` + Plan []PlanStep `json:"plan,omitempty"` +} + +// sideState is the mutable per-chain deployment state during a run. +type sideState struct { + chainID string + rpc string + configuredRouter string + clientCfg config.ClientConfig + client *ethclient.Client + setup *setup + + instance instance + gmp common.Address + ift common.Address + sendCall common.Address + appsSkipped bool + + result *ChainResult +} + +// Connect performs the full one-step flow: deploy contracts where needed, create +// both clients, authorize public relay, and cross-wire the IFT bridges. +func Connect(ctx context.Context, cfg config.Config, alias string, opts Options) (*Result, error) { + return run(ctx, cfg, alias, true, opts) +} + +// Deploy deploys the IBC contracts (instance + GMP/IFT apps) for the connection +// on both chains, without creating clients or wiring bridges. +func Deploy(ctx context.Context, cfg config.Config, alias string, opts Options) (*Result, error) { + return run(ctx, cfg, alias, false, opts) +} + +func run(ctx context.Context, cfg config.Config, alias string, connect bool, opts Options) (*Result, error) { + if opts.Logger == nil { + opts.Logger = slog.Default() + } + if opts.SignerAlias == "" { + opts.SignerAlias = cfg.Relayer.Signer + } + if opts.SignerAlias == "" { + return nil, fmt.Errorf("no deployer signer: pass --signer or set relayer.signer") + } + + local, remote, err := resolvePair(cfg, alias) + if err != nil { + return nil, err + } + + deployer, err := resolveLocalAccount(cfg, opts.SignerAlias) + if err != nil { + return nil, fmt.Errorf("resolve deployer signer: %w", err) + } + + mode := "deploy" + if connect { + mode = "connect" + } + + // Preflight: resolve and validate everything static (attestor addresses, + // client identifiers, configured router addresses, counterparty pairing) + // before any chain is dialed or any transaction is broadcast. Both the + // dry-run and the live run share this step, so a misconfigured attestor set + // fails fast rather than after several on-chain deployments. + pf, err := preflight(cfg, local, remote) + if err != nil { + return nil, err + } + + if opts.DryRun { + return &Result{Mode: mode, DryRun: true, Plan: buildPlan(local, remote, connect, deployer, pf)}, nil + } + + result := &Result{Mode: mode, DryRun: false} + // finalize snapshots each side's progress into result so a partial + // deployment (notably durable router addresses) survives an error return. + finalize := func() *Result { + result.Chains = result.Chains[:0] + for _, side := range []*sideState{local, remote} { + if side.result != nil { + result.Chains = append(result.Chains, *side.result) + } + } + return result + } + + // Dial both chains, bind executors, and verify chain identity BEFORE any tx. + for _, side := range []*sideState{local, remote} { + client, err := ethclient.DialContext(ctx, side.rpc) + if err != nil { + return finalize(), fmt.Errorf("dial chain %q rpc %s: %w", side.chainID, sanitizeRPC(side.rpc), err) + } + defer client.Close() + + // A single eth_chainId query serves both identity verification and the + // setup binding (which needs it to sign chain-scoped transactions). + id, err := client.ChainID(ctx) + if err != nil { + return finalize(), fmt.Errorf("chain %q: query chain id: %w", side.chainID, err) + } + if verifyErr := verifyChainIdentity(side.chainID, id); verifyErr != nil { + return finalize(), verifyErr + } + setup, err := newSetup(client, id) + if err != nil { + return finalize(), fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.client = client + side.setup = setup + side.result = &ChainResult{ChainID: side.chainID} + } + + // Phase 1: instance (AccessManager + router) on both chains. + for _, side := range []*sideState{local, remote} { + if err := deployInstanceForSide(ctx, side, deployer, opts.Logger); err != nil { + return finalize(), err + } + } + + // Phase 2: apps (GMP, IFT, send-call constructor) on both chains. + for _, side := range []*sideState{local, remote} { + if err := deployAppsForSide(ctx, side, deployer, opts.Logger); err != nil { + return finalize(), err + } + } + + // Phase 3: open the router's relayer functions to the public role. + for _, side := range []*sideState{local, remote} { + opts.Logger.Info("authorizing public relay", "chainID", side.chainID) + if err := side.setup.authorizePublicRelay( + ctx, + deployer, + side.instance.AccessManager, + side.instance.Router, + ); err != nil { + return finalize(), fmt.Errorf("chain %q: %w", side.chainID, err) + } + } + + // Phase 4: create clients. Part of both `deploy` and `connect`. The initial + // height/timestamp of each client come from the COUNTERPARTY chain's header. + if err := deployClientForSide(ctx, local, remote, pf.local, deployer, opts.Logger); err != nil { + return finalize(), err + } + if err := deployClientForSide(ctx, remote, local, pf.remote, deployer, opts.Logger); err != nil { + return finalize(), err + } + + if !connect { + return finalize(), nil + } + + // Phase 5: cross-wire IFT bridges (the only step `connect` adds over + // `deploy`). Only possible when both sides deployed their apps this run (the + // IFT addresses are not persisted in config). + if err := wireBridges(ctx, local, remote, deployer, opts.Logger); err != nil { + return finalize(), err + } + + return finalize(), nil +} + +// verifyChainIdentity guards against deploying to the wrong chain by requiring +// the configured chainId to equal the value returned by eth_chainId. The compare +// is numeric so "0x..."-free decimal config strings match the big.Int the RPC +// reports. +func verifyChainIdentity(configuredChainID string, fetched *big.Int) error { + want, ok := new(big.Int).SetString(configuredChainID, 10) + if !ok { + return fmt.Errorf( + "chain %q: configured chainId is not a base-10 number; cannot verify it against on-chain eth_chainId %s", + configuredChainID, fetched) + } + if want.Cmp(fetched) != 0 { + return fmt.Errorf( + "chain identity mismatch: configured chainId %q but the RPC eth_chainId returned %s; "+ + "refusing to deploy to the wrong chain", + configuredChainID, fetched) + } + return nil +} + +// sanitizeRPC strips credentials (userinfo) and query parameters from an RPC URL +// so dial errors never leak tokens embedded in the endpoint. +func sanitizeRPC(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + u.User = nil + u.RawQuery = "" + return u.String() +} + +func deployInstanceForSide(ctx context.Context, side *sideState, deployer Account, log *slog.Logger) error { + if router := side.configuredRouter; router != "" { + // Address validity was checked in preflight before any chain was dialed. + log.Info("router already configured, attaching existing instance", "chainID", side.chainID, "router", router) + inst, err := side.setup.attachInstance(ctx, common.HexToAddress(router)) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.instance = inst + side.result.Skipped = append(side.result.Skipped, "instance (router already configured)") + } else { + log.Info("deploying instance (AccessManager + ICS26Router)", "chainID", side.chainID) + inst, err := side.setup.deployInstance(ctx, deployer) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.instance = inst + } + side.result.AccessManager = side.instance.AccessManager.Hex() + side.result.ICS26Router = side.instance.Router.Hex() + return nil +} + +func deployAppsForSide(ctx context.Context, side *sideState, deployer Account, log *slog.Logger) error { + registered, err := side.setup.gmpAppRegistered(ctx, side.instance.Router) + if err != nil { + return fmt.Errorf("chain %q: check GMP app: %w", side.chainID, err) + } + if registered { + // Partial/existing app state. Recover the GMP address for the report, + // then fail loud: V1 cannot prove the rest of the app graph (IFT token, + // send-call constructor, and bridge wiring) from chain state alone, so it + // refuses to silently redeploy IFT over a live connection or to report a + // half-wired connection as a success. + gmp, addrErr := side.setup.gmpAppAddress(ctx, side.instance.Router) + if addrErr != nil { + return fmt.Errorf("chain %q: recover existing GMP app address: %w", side.chainID, addrErr) + } + side.gmp = gmp + side.result.GMP = gmp.Hex() + side.appsSkipped = true + side.result.Skipped = append(side.result.Skipped, "apps (GMP port already registered)") + return fmt.Errorf( + "chain %q: GMP port %q is already registered (GMP app at %s): partial app state detected. "+ + "deploy/connect cannot recover the IFT token, send-call constructor, or IFT bridge wiring from "+ + "chain state alone, so it will not redeploy over an existing connection. If this connection is "+ + "already fully wired, no action is needed. Otherwise re-run on fresh client IDs, or finish wiring "+ + "the remaining contracts manually", + side.chainID, gmpPortID, gmp) + } + + log.Info("deploying GMP app", "chainID", side.chainID) + gmp, err := side.setup.deployGMPApp(ctx, deployer, side.instance.Router, side.instance.AccessManager) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.gmp = gmp + side.result.GMP = gmp.Hex() + + log.Info("deploying IFT token", "chainID", side.chainID) + ift, err := side.setup.deployIFT(ctx, deployer, deployer.Address(), gmp, iftName(side.chainID), iftSymbol) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.ift = ift + side.result.IFT = ift.Hex() + + log.Info("deploying IFT send-call constructor", "chainID", side.chainID) + sendCall, err := side.setup.deploySendCallConstructor(ctx, deployer) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.sendCall = sendCall + side.result.SendCallConstructor = sendCall.Hex() + return nil +} + +func deployClientForSide( + ctx context.Context, + side *sideState, + counterparty *sideState, + pf sidePreflight, + deployer Account, + log *slog.Logger, +) error { + clientID := side.clientCfg.ClientID + registered, err := side.setup.clientRegistered(ctx, side.instance.Router, clientID) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + if registered { + log.Info("client already registered, attaching", "chainID", side.chainID, "clientID", clientID) + client, attachErr := side.setup.attachClient( + ctx, side.instance.Router, clientID, side.clientCfg.CounterpartyClientID, + ) + if attachErr != nil { + return fmt.Errorf("chain %q: %w", side.chainID, attachErr) + } + // The counterparty client ID was verified inside attachClient. Also + // require the on-chain trust policy (attestor set + threshold) to match + // the config-resolved policy: reusing a client with a different set would + // silently run under the wrong trust assumptions. + if policyErr := requireTrustPolicyMatch( + side.chainID, clientID, client, pf.attestors, pf.threshold, + ); policyErr != nil { + return policyErr + } + side.result.Client = &ClientRes{ClientID: client.ID, Address: client.Address.Hex()} + side.result.Skipped = append(side.result.Skipped, fmt.Sprintf("client %q (already registered)", clientID)) + return nil + } + + height, timestamp, err := currentHeader(ctx, counterparty.client) + if err != nil { + return fmt.Errorf("chain %q: read counterparty %q header: %w", side.chainID, counterparty.chainID, err) + } + + log.Info("deploying client", "chainID", side.chainID, "clientID", clientID, + "counterpartyHeight", height, "counterpartyTimestamp", timestamp) + client, err := side.setup.deployClient(ctx, deployer, side.instance.Router, attestationClientConfig{ + ID: clientID, + CounterpartyClientID: side.clientCfg.CounterpartyClientID, + Attestors: pf.attestors, + MinRequiredSignatures: pf.threshold, + InitialHeight: height, + InitialTimestamp: timestamp, + }) + if err != nil { + return fmt.Errorf("chain %q: %w", side.chainID, err) + } + side.result.Client = &ClientRes{ClientID: client.ID, Address: client.Address.Hex()} + return nil +} + +// requireTrustPolicyMatch fails when an already-registered client's on-chain +// attestor set or signature threshold differs from the config-resolved policy. +func requireTrustPolicyMatch( + chainID, clientID string, + onChain client, + wantAttestors []common.Address, + wantThreshold uint8, +) error { + if onChain.MinRequiredSignatures == wantThreshold && sameAddressSet(onChain.Attestors, wantAttestors) { + return nil + } + return fmt.Errorf( + "chain %q: client %q is already registered with a trust policy that does not match config: "+ + "on-chain %d-of-%d attestors %s, config %d-of-%d attestors %s. "+ + "Refusing to reuse a client whose attestor set or threshold differs; "+ + "deploy on a fresh client ID or reconcile the config", + chainID, clientID, + onChain.MinRequiredSignatures, len(onChain.Attestors), formatAddrs(onChain.Attestors), + wantThreshold, len(wantAttestors), formatAddrs(wantAttestors)) +} + +// sameAddressSet reports whether two address slices contain the same addresses, +// independent of order. +func sameAddressSet(a, b []common.Address) bool { + if len(a) != len(b) { + return false + } + counts := make(map[common.Address]int, len(a)) + for _, addr := range a { + counts[addr]++ + } + for _, addr := range b { + counts[addr]-- + if counts[addr] < 0 { + return false + } + } + return true +} + +// formatAddrs renders an address set as a sorted, human-readable list for +// diff-style error messages. +func formatAddrs(addrs []common.Address) string { + hexes := make([]string, len(addrs)) + for i, addr := range addrs { + hexes[i] = addr.Hex() + } + sort.Strings(hexes) + return "[" + strings.Join(hexes, ", ") + "]" +} + +func wireBridges(ctx context.Context, local, remote *sideState, deployer Account, log *slog.Logger) error { + if local.appsSkipped || remote.appsSkipped { + log.Warn("IFT bridge wiring skipped: apps were not (re)deployed on one or both chains this run", + "localAppsSkipped", local.appsSkipped, "remoteAppsSkipped", remote.appsSkipped) + local.result.Skipped = append(local.result.Skipped, "IFT bridge wiring (apps not redeployed)") + remote.result.Skipped = append(remote.result.Skipped, "IFT bridge wiring (apps not redeployed)") + return nil + } + + log.Info("registering IFT bridge", "chainID", local.chainID, "clientID", local.clientCfg.ClientID) + if err := local.setup.registerIFTBridge( + ctx, deployer, local.ift, local.clientCfg.ClientID, remote.ift.Hex(), local.sendCall, + ); err != nil { + return fmt.Errorf("chain %q: %w", local.chainID, err) + } + + log.Info("registering IFT bridge", "chainID", remote.chainID, "clientID", remote.clientCfg.ClientID) + if err := remote.setup.registerIFTBridge( + ctx, deployer, remote.ift, remote.clientCfg.ClientID, local.ift.Hex(), remote.sendCall, + ); err != nil { + return fmt.Errorf("chain %q: %w", remote.chainID, err) + } + return nil +} + +// currentHeader returns the latest block height and timestamp from a chain. +func currentHeader(ctx context.Context, client *ethclient.Client) (uint64, uint64, error) { + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + return 0, 0, err + } + return header.Number.Uint64(), header.Time, nil +} diff --git a/link/internal/deploy/deploy_test.go b/link/internal/deploy/deploy_test.go new file mode 100644 index 000000000..9528aadbb --- /dev/null +++ b/link/internal/deploy/deploy_test.go @@ -0,0 +1,199 @@ +package deploy + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifyChainIdentity(t *testing.T) { + require.NoError(t, verifyChainIdentity("31337", big.NewInt(31337))) + + err := verifyChainIdentity("31337", big.NewInt(1)) + require.ErrorContains(t, err, "chain identity mismatch") + require.ErrorContains(t, err, "31337") + require.ErrorContains(t, err, "returned 1") + + err = verifyChainIdentity("mainnet", big.NewInt(1)) + require.ErrorContains(t, err, "not a base-10 number") +} + +func TestSanitizeRPC(t *testing.T) { + require.Equal(t, "https://rpc.example.com/v1", sanitizeRPC("https://rpc.example.com/v1")) + // userinfo and query (which may carry API keys) are stripped. + require.Equal(t, "https://rpc.example.com/v1", + sanitizeRPC("https://user:secret@rpc.example.com/v1?apikey=deadbeef")) + require.Equal(t, "https://rpc.example.com/", sanitizeRPC("https://rpc.example.com/?token=abc123")) +} + +func TestSameAddressSet(t *testing.T) { + a := common.HexToAddress("0x1111111111111111111111111111111111111111") + b := common.HexToAddress("0x2222222222222222222222222222222222222222") + c := common.HexToAddress("0x3333333333333333333333333333333333333333") + + require.True(t, sameAddressSet([]common.Address{a, b}, []common.Address{b, a})) + require.False(t, sameAddressSet([]common.Address{a, b}, []common.Address{a, c})) + require.False(t, sameAddressSet([]common.Address{a}, []common.Address{a, b})) +} + +func TestRequireTrustPolicyMatch(t *testing.T) { + a := common.HexToAddress("0x1111111111111111111111111111111111111111") + b := common.HexToAddress("0x2222222222222222222222222222222222222222") + + onChain := client{Attestors: []common.Address{a, b}, MinRequiredSignatures: 1} + require.NoError(t, requireTrustPolicyMatch("chain-a", "cid", onChain, []common.Address{b, a}, 1)) + + // Threshold differs (1-of-2 on chain vs config 2-of-2): precise diff error. + err := requireTrustPolicyMatch("chain-a", "cid", onChain, []common.Address{a, b}, 2) + require.ErrorContains(t, err, "does not match config") + require.ErrorContains(t, err, "on-chain 1-of-2") + require.ErrorContains(t, err, "config 2-of-2") + + // Attestor set differs. + c := common.HexToAddress("0x3333333333333333333333333333333333333333") + err = requireTrustPolicyMatch("chain-a", "cid", onChain, []common.Address{a, c}, 1) + require.ErrorContains(t, err, "does not match config") +} + +// TestPatchConfigFilePreservesCommentsAndEnv verifies the surgical file patch +// only rewrites router scalars and leaves comments and ${ENV} placeholders +// untouched. +func TestPatchConfigFilePreservesCommentsAndEnv(t *testing.T) { + const original = `# top-level comment +chains: + - chainId: "31337" # the local chain + evm: + rpc: https://user:${RPC_TOKEN}@rpc.local/v1 # keep this secret placeholder + ics26Router: "0x0000000000000000000000000000000000000000" +attestor: + attestations: + - chainId: "31337" + name: att1 + evm: + routerAddress: "0x0000000000000000000000000000000000000000" +db: + url: postgres://user:${DB_PASSWORD}@db/ibc # keep this too +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(original), 0o644)) + + router := "0x1234567890AbcdEF1234567890aBcdef12345678" + result := &Result{Chains: []ChainResult{{ChainID: "31337", ICS26Router: router}}} + + changes, err := PatchConfigFile(path, result) + require.NoError(t, err) + require.Len(t, changes, 2) + + patched, err := os.ReadFile(path) + require.NoError(t, err) + text := string(patched) + + require.Contains(t, text, router, "router address must be written") + require.NotContains(t, text, "0x0000000000000000000000000000000000000000", "old router must be gone") + require.Contains(t, text, "# top-level comment", "comments preserved") + require.Contains(t, text, "# keep this secret placeholder", "inline comment preserved") + require.Contains(t, text, "${RPC_TOKEN}", "unexpanded RPC token placeholder preserved") + require.Contains(t, text, "${DB_PASSWORD}", "unexpanded DB password placeholder preserved") + + // Re-running with the same result is a no-op (idempotent, no rewrite). + changes, err = PatchConfigFile(path, result) + require.NoError(t, err) + require.Empty(t, changes) +} + +// TestPatchConfigFilePreservesModeAndIsAtomic verifies the in-place patch +// preserves the original file's permission bits and leaves no temp file behind +// (it writes to a sibling temp file and renames it into place). +func TestPatchConfigFilePreservesModeAndIsAtomic(t *testing.T) { + const original = `chains: + - chainId: "31337" + evm: + rpc: https://rpc.local/v1 + ics26Router: "0x0000000000000000000000000000000000000000" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + const mode os.FileMode = 0o600 + require.NoError(t, os.WriteFile(path, []byte(original), mode)) + + router := "0x1234567890AbcdEF1234567890aBcdef12345678" + result := &Result{Chains: []ChainResult{{ChainID: "31337", ICS26Router: router}}} + + changes, err := PatchConfigFile(path, result) + require.NoError(t, err) + require.NotEmpty(t, changes) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, mode, info.Mode().Perm(), "original file mode must be preserved") + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 1, "no temp file should be left behind") + assert.Equal(t, "config.yaml", entries[0].Name()) +} + +// TestPatchConfigFileThroughSymlink verifies that patching a config reached via +// a symlink updates the real target in place: the symlink stays a symlink +// pointing at the same target, the target's mode is preserved, and no temp file +// is left behind. Renaming over the link node itself would silently turn it into +// a regular file and leave the real target stale. +func TestPatchConfigFileThroughSymlink(t *testing.T) { + const original = `chains: + - chainId: "31337" + evm: + rpc: https://rpc.local/v1 + ics26Router: "0x0000000000000000000000000000000000000000" +` + dir := t.TempDir() + targetDir := filepath.Join(dir, "target") + require.NoError(t, os.Mkdir(targetDir, 0o755)) + + target := filepath.Join(targetDir, "config.yaml") + const mode os.FileMode = 0o600 + require.NoError(t, os.WriteFile(target, []byte(original), mode)) + + link := filepath.Join(dir, "config.yaml") + require.NoError(t, os.Symlink(target, link)) + + router := "0x1234567890AbcdEF1234567890aBcdef12345678" + result := &Result{Chains: []ChainResult{{ChainID: "31337", ICS26Router: router}}} + + changes, err := PatchConfigFile(link, result) + require.NoError(t, err) + require.NotEmpty(t, changes) + + // The link must still be a symlink pointing at the same target. + linkInfo, err := os.Lstat(link) + require.NoError(t, err) + require.NotZero(t, linkInfo.Mode()&os.ModeSymlink, "link must remain a symlink, not become a regular file") + dest, err := os.Readlink(link) + require.NoError(t, err) + assert.Equal(t, target, dest, "symlink must still point at the original target") + + // The real target must be patched in place with its mode preserved. + patched, err := os.ReadFile(target) + require.NoError(t, err) + require.Contains(t, string(patched), router, "target must contain the new router address") + require.NotContains(t, string(patched), "0x0000000000000000000000000000000000000000", "old router must be gone") + + targetInfo, err := os.Stat(target) + require.NoError(t, err) + assert.Equal(t, mode, targetInfo.Mode().Perm(), "target file mode must be preserved") + + // No temp file left behind in either directory. + linkDirEntries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, linkDirEntries, 2, "link dir should hold only the symlink and target dir") + + targetDirEntries, err := os.ReadDir(targetDir) + require.NoError(t, err) + require.Len(t, targetDirEntries, 1, "no temp file should be left behind next to the target") + assert.Equal(t, "config.yaml", targetDirEntries[0].Name()) +} diff --git a/link/internal/deploy/resolve.go b/link/internal/deploy/resolve.go new file mode 100644 index 000000000..54509af04 --- /dev/null +++ b/link/internal/deploy/resolve.go @@ -0,0 +1,234 @@ +package deploy + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/link/internal/config" +) + +const iftSymbol = "IFT" + +func iftName(chainID string) string { + return "IBC Fungible Token " + chainID +} + +// resolvePair resolves the local client (by alias) and its counterparty client +// from config, building the per-chain state (chain id, rpc, configured router) +// for both sides. The counterparty must also be configured as a client. +func resolvePair(cfg config.Config, alias string) (local *sideState, remote *sideState, err error) { + localCfg, ok := cfg.Relayer.ClientByAlias(alias) + if !ok { + return nil, nil, fmt.Errorf("no relayer client with alias %q", alias) + } + + remoteCfg, ok := cfg.Relayer.Client(localCfg.CounterpartyChainID, localCfg.CounterpartyClientID) + if !ok { + return nil, nil, fmt.Errorf( + "counterparty client %q on chain %q is not configured; connect needs both sides", + localCfg.CounterpartyClientID, localCfg.CounterpartyChainID, + ) + } + + localSide, err := newSide(cfg, localCfg) + if err != nil { + return nil, nil, err + } + remoteSide, err := newSide(cfg, remoteCfg) + if err != nil { + return nil, nil, err + } + return localSide, remoteSide, nil +} + +func newSide(cfg config.Config, clientCfg config.ClientConfig) (*sideState, error) { + chain, ok := cfg.Chain(clientCfg.ChainID) + if !ok { + return nil, fmt.Errorf("chain %q referenced by client %q is not configured", clientCfg.ChainID, clientCfg.Alias) + } + if chain.Type() != config.ChainTypeEVM || chain.EVM == nil { + return nil, fmt.Errorf("chain %q is not an EVM chain; deploy only supports EVM", clientCfg.ChainID) + } + if chain.EVM.RPC == "" { + return nil, fmt.Errorf("chain %q has no evm.rpc configured", clientCfg.ChainID) + } + return &sideState{ + chainID: clientCfg.ChainID, + rpc: chain.EVM.RPC, + configuredRouter: chain.EVM.ICS26Router, + clientCfg: clientCfg, + }, nil +} + +// resolveAttestors resolves the on-chain attestor addresses and signature +// threshold for a client's attestor set. Address resolution per entry: +// - entry.EVMAddress set -> use it +// - entry.Type == local -> derive from the matching +// attestor.attestations[].name entry's local signer key file +// - otherwise -> error +func resolveAttestors(cfg config.Config, clientCfg config.ClientConfig) ([]common.Address, uint8, error) { + set := clientCfg.AttestorSet + if set == nil { + return nil, 0, fmt.Errorf("client %q has no attestorSet", clientCfg.Alias) + } + // Config validation enforces 1 <= threshold <= len(attestors); this bounds + // check only guards the uint8 conversion below against a wrap-around that + // would otherwise silently pass validateStatic. + if set.Threshold < 1 || set.Threshold > 255 { + return nil, 0, fmt.Errorf("client %q attestor threshold %d is out of range", clientCfg.Alias, set.Threshold) + } + + addrs := make([]common.Address, 0, len(set.Attestors)) + for _, entry := range set.Attestors { + addr, err := resolveAttestorAddress(cfg, entry) + if err != nil { + return nil, 0, fmt.Errorf("client %q attestor %q: %w", clientCfg.Alias, entry.Name, err) + } + addrs = append(addrs, addr) + } + return addrs, uint8(set.Threshold), nil +} + +func resolveAttestorAddress(cfg config.Config, entry config.AttestorEntry) (common.Address, error) { + if entry.EVMAddress != "" { + if !common.IsHexAddress(entry.EVMAddress) { + return common.Address{}, fmt.Errorf("evmAddress %q is not a valid hex address", entry.EVMAddress) + } + return common.HexToAddress(entry.EVMAddress), nil + } + + if entry.Type != config.AttestorTypeLocal { + return common.Address{}, fmt.Errorf( + "attestor is %q and has no evmAddress; set evmAddress to its on-chain signer address", entry.Type) + } + + // Derive from the matching attestor.attestations[].name entry's signer key. + attestation, ok := attestationByName(cfg, entry.Name) + if !ok { + return common.Address{}, fmt.Errorf( + "no attestor.attestations entry named %q to derive its EVM address; set evmAddress instead", entry.Name) + } + acct, err := resolveLocalAccount(cfg, attestation.Signer) + if err != nil { + return common.Address{}, fmt.Errorf("derive EVM address from attestation %q: %w", entry.Name, err) + } + return acct.Address(), nil +} + +func attestationByName(cfg config.Config, name string) (config.AttestationConfig, bool) { + for _, a := range cfg.Attestor.Attestations { + if a.Name == name { + return a, true + } + } + return config.AttestationConfig{}, false +} + +// sidePreflight is the resolved, statically-validated deployment inputs for one +// chain: its attestor set and signature threshold. +type sidePreflight struct { + attestors []common.Address + threshold uint8 +} + +// preflightResult carries both sides' resolved inputs from the preflight step +// through the live deployment so nothing static is re-resolved mid-flight. +type preflightResult struct { + local sidePreflight + remote sidePreflight +} + +// preflight resolves and validates every config-derived input before any chain +// is dialed or any transaction is broadcast: configured router address validity, +// attestor address resolution (including local key files and duplicate detection), +// and Solidity client-ID validity. Counterparty pairing was already resolved by +// resolvePair. Both the dry-run and the live run call this so static failures +// surface up front rather than after several on-chain deployments. +func preflight(cfg config.Config, local, remote *sideState) (*preflightResult, error) { + res := &preflightResult{} + for _, item := range []struct { + side *sideState + out *sidePreflight + }{ + {local, &res.local}, + {remote, &res.remote}, + } { + side := item.side + if router := side.configuredRouter; router != "" && !common.IsHexAddress(router) { + return nil, fmt.Errorf("chain %q: configured ics26Router %q is not a valid address", side.chainID, router) + } + attestors, threshold, err := resolveAttestors(cfg, side.clientCfg) + if err != nil { + return nil, fmt.Errorf("chain %q: %w", side.chainID, err) + } + cc := attestationClientConfig{ + ID: side.clientCfg.ClientID, + CounterpartyClientID: side.clientCfg.CounterpartyClientID, + Attestors: attestors, + MinRequiredSignatures: threshold, + } + if err := cc.validateStatic(); err != nil { + return nil, fmt.Errorf("chain %q: %w", side.chainID, err) + } + item.out.attestors = attestors + item.out.threshold = threshold + } + return res, nil +} + +// buildPlan produces the ordered step list for a dry-run from the preflight +// result. It never dials a chain. `deploy` and `connect` share every step except +// the cross-chain IFT bridge wiring, which only `connect` performs. +func buildPlan(local, remote *sideState, connect bool, deployer Account, pf *preflightResult) []PlanStep { + var steps []PlanStep + add := func(chainID, action, detail string) { + steps = append(steps, PlanStep{ChainID: chainID, Action: action, Detail: detail}) + } + + // Instance + apps + client + authorize on both chains. + for _, item := range []struct { + side *sideState + pf sidePreflight + counterparty *sideState + }{ + {local, pf.local, remote}, + {remote, pf.remote, local}, + } { + side := item.side + if side.configuredRouter != "" { + add(side.chainID, "attach-instance", "router already configured: "+side.configuredRouter) + } else { + add( + side.chainID, + "deploy-instance", + "AccessManager + ICS26Router impl + ERC1967 proxy (admin "+deployer.Address().Hex()+")", + ) + } + add(side.chainID, "deploy-gmp", "ICS27Account logic + ICS27GMP logic + proxy + AddIBCApp0(gmpport)") + add(side.chainID, "deploy-ift", "IFT logic + proxy (owner "+deployer.Address().Hex()+")") + add(side.chainID, "deploy-sendcall", "EVMIFTSendCallConstructor") + add(side.chainID, "deploy-client", fmt.Sprintf( + "attestation client %q (counterparty %q on %q), %d/%d attestors, initial height/timestamp from %q header", + side.clientCfg.ClientID, side.clientCfg.CounterpartyClientID, side.clientCfg.CounterpartyChainID, + item.pf.threshold, len(item.pf.attestors), item.counterparty.chainID)) + add(side.chainID, "authorize-public-relay", "setTargetFunctionRole(recv/timeout/ack/updateClient -> public)") + } + + if !connect { + return steps + } + + add( + local.chainID, + "register-ift-bridge", + fmt.Sprintf("client %q -> counterparty IFT on %q", local.clientCfg.ClientID, remote.chainID), + ) + add( + remote.chainID, + "register-ift-bridge", + fmt.Sprintf("client %q -> counterparty IFT on %q", remote.clientCfg.ClientID, local.chainID), + ) + + return steps +} diff --git a/link/internal/deploy/resolve_test.go b/link/internal/deploy/resolve_test.go new file mode 100644 index 000000000..6c337802c --- /dev/null +++ b/link/internal/deploy/resolve_test.go @@ -0,0 +1,232 @@ +package deploy + +import ( + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +// pairConfig builds a minimal two-chain, two-client config with a local +// attestor whose address is derived from a key file under home. +func pairConfig(t *testing.T, home string) config.Config { + t.Helper() + + keyPath := filepath.Join(home, "keys", "att1.json") + key, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + require.NoError(t, key.StoreToFile(keyPath)) + + return config.Config{ + Chains: []config.ChainConfig{ + {ChainID: "chain-a", EVM: &config.EVMChainConfig{RPC: "http://a:8545"}}, + {ChainID: "chain-b", EVM: &config.EVMChainConfig{RPC: "http://b:8545"}}, + }, + Signers: config.Signers{ + {Alias: "att1", Type: config.SignerLocal, File: keyPath}, + {Alias: "relayer1", Type: config.SignerLocal, File: keyPath}, + }, + Attestor: config.AttestorConfig{ + Attestations: []config.AttestationConfig{ + {ChainID: "chain-a", Name: "att1", Signer: "att1", EVM: &config.AttestationEVMConfig{RouterAddress: "0x0000000000000000000000000000000000000001"}}, + }, + }, + Relayer: config.RelayerConfig{ + Signer: "relayer1", + Clients: []config.ClientConfig{ + { + Alias: "a-to-b", ClientID: "chainb-client", ChainID: "chain-a", + CounterpartyChainID: "chain-b", CounterpartyClientID: "chaina-client", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{{Name: "att1", Type: config.AttestorTypeLocal}}, + }, + }, + { + Alias: "b-to-a", ClientID: "chaina-client", ChainID: "chain-b", + CounterpartyChainID: "chain-a", CounterpartyClientID: "chainb-client", + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att-b", Type: config.AttestorTypeRemote, GRPC: "b:9090", EVMAddress: "0x00000000000000000000000000000000000000aB"}, + }, + }, + }, + }, + }, + } +} + +func TestResolvePair(t *testing.T) { + cfg := pairConfig(t, t.TempDir()) + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + require.Equal(t, "chain-a", local.chainID) + require.Equal(t, "http://a:8545", local.rpc) + require.Equal(t, "chainb-client", local.clientCfg.ClientID) + require.Equal(t, "chain-b", remote.chainID) + require.Equal(t, "chaina-client", remote.clientCfg.ClientID) +} + +func TestResolvePairMissingCounterparty(t *testing.T) { + cfg := pairConfig(t, t.TempDir()) + // Drop the counterparty client so pairing fails. + cfg.Relayer.Clients = cfg.Relayer.Clients[:1] + + _, _, err := resolvePair(cfg, "a-to-b") + require.ErrorContains(t, err, "counterparty client") + require.ErrorContains(t, err, "connect needs both sides") +} + +func TestResolvePairUnknownAlias(t *testing.T) { + cfg := pairConfig(t, t.TempDir()) + _, _, err := resolvePair(cfg, "nope") + require.ErrorContains(t, err, `no relayer client with alias "nope"`) +} + +func TestResolveAttestorsFromLocalKeyFile(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + + // Derive the expected address directly from the stored key. + keyPath := filepath.Join(home, "keys", "att1.json") + key, err := signer.LocalKeyFromFile(keyPath) + require.NoError(t, err) + want, err := AccountFromPrivateKeyBytes(key.PrivateKey()) + require.NoError(t, err) + + local, _, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + addrs, threshold, err := resolveAttestors(cfg, local.clientCfg) + require.NoError(t, err) + require.Equal(t, uint8(1), threshold) + require.Len(t, addrs, 1) + require.Equal(t, want.Address(), addrs[0]) +} + +func TestResolveAttestorsFromEVMAddress(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + + _, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + addrs, threshold, err := resolveAttestors(cfg, remote.clientCfg) + require.NoError(t, err) + require.Equal(t, uint8(1), threshold) + require.Len(t, addrs, 1) + require.Equal(t, common.HexToAddress("0x00000000000000000000000000000000000000aB"), addrs[0]) +} + +func TestResolveAttestorsRemoteWithoutAddressErrors(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + // Make the remote-side attestor remote with no evmAddress: unresolvable. + _, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + remote.clientCfg.AttestorSet.Attestors[0].EVMAddress = "" + + _, _, err = resolveAttestors(cfg, remote.clientCfg) + require.ErrorContains(t, err, "has no evmAddress") +} + +func TestBuildPlanDeployVsConnect(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + deployer, err := newTestAccount() + require.NoError(t, err) + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + pf, err := preflight(cfg, local, remote) + require.NoError(t, err) + + // `deploy` now includes clients (per the PRD) but never the bridge wiring. + deployPlan := buildPlan(local, remote, false, deployer, pf) + require.NotEmpty(t, deployPlan) + var deployClients int + for _, step := range deployPlan { + if step.Action == "deploy-client" { + deployClients++ + } + require.NotEqual(t, "register-ift-bridge", step.Action, "deploy plan must not wire bridges") + } + require.Equal(t, 2, deployClients, "deploy plan creates a client on each chain") + + connectPlan := buildPlan(local, remote, true, deployer, pf) + require.Greater(t, len(connectPlan), len(deployPlan)) + + var clients, bridges int + for _, step := range connectPlan { + switch step.Action { + case "deploy-client": + clients++ + case "register-ift-bridge": + bridges++ + } + } + require.Equal(t, 2, clients, "connect plan creates a client on each chain") + require.Equal(t, 2, bridges, "connect plan wires a bridge on each chain") +} + +func TestPreflightResolvesBothSides(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + pf, err := preflight(cfg, local, remote) + require.NoError(t, err) + require.Len(t, pf.local.attestors, 1) + require.Equal(t, uint8(1), pf.local.threshold) + require.Len(t, pf.remote.attestors, 1) + require.Equal(t, uint8(1), pf.remote.threshold) +} + +func TestPreflightFailsOnUnresolvableAttestor(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + // Make the remote-side attestor remote with no evmAddress: unresolvable. + cfg.Relayer.Clients[1].AttestorSet.Attestors[0].EVMAddress = "" + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + _, err = preflight(cfg, local, remote) + require.ErrorContains(t, err, "has no evmAddress") + require.ErrorContains(t, err, "chain \"chain-b\"") +} + +func TestPreflightFailsOnInvalidRouterAddress(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + cfg.Chains[0].EVM.ICS26Router = "not-an-address" + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + _, err = preflight(cfg, local, remote) + require.ErrorContains(t, err, "is not a valid address") +} + +func TestPreflightFailsOnInvalidClientID(t *testing.T) { + home := t.TempDir() + cfg := pairConfig(t, home) + cfg.Relayer.Clients[0].ClientID = "client-0" // reserved generated prefix + + local, remote, err := resolvePair(cfg, "a-to-b") + require.NoError(t, err) + + _, err = preflight(cfg, local, remote) + require.ErrorContains(t, err, "not a valid Solidity IBC custom client identifier") +} diff --git a/link/internal/deploy/setup.go b/link/internal/deploy/setup.go new file mode 100644 index 000000000..39fb7dd03 --- /dev/null +++ b/link/internal/deploy/setup.go @@ -0,0 +1,824 @@ +package deploy + +import ( + "context" + "fmt" + "math/big" + "slices" + + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/attestation" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/erc1967proxy" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics27account" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics27gmp" + "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ift" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cosmos/ibc/link/internal/pkg/evmrevert" +) + +// gmpPortID mirrors ICS27Lib.DEFAULT_PORT_ID in Solidity IBC v3: the port the +// ICS27 GMP app registers under on the ICS26 router. +const gmpPortID = "gmpport" + +// publicRole mirrors IBCRolesLib.PUBLIC_ROLE (type(uint64).max): a target +// function assigned this role may be called by any address. +const publicRole uint64 = 1<<64 - 1 + +// ics26RelayerSelectorNames are the ICS26 router functions the truthful Relayer +// submits. Mirrors IBCRolesLib.ics26RelayerSelectors so relaying can be opened to +// a non-admin signer without minting a bespoke role. +var ics26RelayerSelectorNames = []string{"recvPacket", "timeoutPacket", "ackPacket", "updateClient"} + +type contractBackend interface { + bind.ContractBackend + bind.DeployBackend +} + +// setup performs Solidity IBC setup transactions on one EVM chain. It borrows +// the backend and never closes it. +type setup struct { + backend contractBackend + chainID *big.Int +} + +// newSetup binds Solidity IBC realization to an already-connected EVM client. The +// link evm.Submitter rejects contract-creation txs, so deploy talks to the +// go-ethereum client directly (it satisfies bind's ContractBackend and +// DeployBackend). chainID must be the client's eth_chainId, already fetched by +// the caller. +func newSetup(backend contractBackend, chainID *big.Int) (*setup, error) { + if backend == nil { + return nil, fmt.Errorf("solidity IBC setup: nil contract backend") + } + if chainID == nil || chainID.Sign() <= 0 { + return nil, fmt.Errorf("solidity IBC setup: invalid EVM chain id") + } + return &setup{backend: backend, chainID: new(big.Int).Set(chainID)}, nil +} + +// deployInstance deploys AccessManager, ICS26Router implementation, and an +// initialized ERC1967 proxy in dependency order. The proxy address is the +// router locator used by clients and relayers. +func (s *setup) deployInstance(ctx context.Context, authority Account) (instance, error) { + accessABI, accessBytecode, err := loadAccessManagerArtifact() + if err != nil { + return instance{}, fmt.Errorf("solidity IBC deploy Instance: %w", err) + } + if authorityErr := validateAuthority(authority); authorityErr != nil { + return instance{}, fmt.Errorf("solidity IBC deploy Instance: %w", authorityErr) + } + + accessAddress, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := bind.DeployContract( + opts, + accessABI, + accessBytecode, + s.backend, + authority.Address(), + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC deploy AccessManager: %w", err) + } + accessReceipt, err := s.awaitMined(ctx, "deploy AccessManager", tx) + if err != nil { + return instance{}, err + } + if deploymentErr := requireDeploymentAddress("AccessManager", accessAddress, accessReceipt); deploymentErr != nil { + return instance{}, deploymentErr + } + if adminErr := s.requireAdmin(ctx, accessAddress, authority.Address()); adminErr != nil { + return instance{}, fmt.Errorf("solidity IBC verify AccessManager: %w", adminErr) + } + + routerImplementation, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics26router.DeployContract(opts, s.backend) + return address, transaction, deployErr + }, + ) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC deploy ICS26Router implementation: %w", err) + } + implementationReceipt, err := s.awaitMined(ctx, "deploy ICS26Router implementation", tx) + if err != nil { + return instance{}, err + } + if deploymentErr := requireDeploymentAddress( + "ICS26Router implementation", + routerImplementation, + implementationReceipt, + ); deploymentErr != nil { + return instance{}, deploymentErr + } + + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: %w", err) + } + if routerABI == nil { + return instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: upstream binding has no ABI") + } + initialization, err := routerABI.Pack("initialize", accessAddress) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC encode ICS26Router initialization: %w", err) + } + + routerProxy, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract( + opts, + s.backend, + routerImplementation, + initialization, + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC deploy initialized ICS26Router proxy: %w", err) + } + proxyReceipt, err := s.awaitMined(ctx, "deploy initialized ICS26Router proxy", tx) + inst := instance{AccessManager: accessAddress, Router: routerProxy} + if err != nil { + return instance{}, err + } + if err := requireDeploymentAddress("ICS26Router proxy", routerProxy, proxyReceipt); err != nil { + return instance{}, err + } + if _, err := s.attachInstance(ctx, routerProxy); err != nil { + return instance{}, fmt.Errorf("solidity IBC verify deployed Instance: %w", err) + } + return inst, nil +} + +// attachInstance resolves the AccessManager from the router locator and verifies +// that both addresses contain the expected contract interfaces. +func (s *setup) attachInstance(ctx context.Context, routerAddress common.Address) (instance, error) { + if routerAddress == (common.Address{}) { + return instance{}, fmt.Errorf("solidity IBC attach Instance: zero ICS26Router address") + } + if err := s.requireCode(ctx, "ICS26Router", routerAddress); err != nil { + return instance{}, err + } + router, err := ics26router.NewContract(routerAddress, s.backend) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC attach Instance: bind ICS26Router: %w", err) + } + authority, err := router.Authority(&bind.CallOpts{Context: ctx}) + if err != nil { + return instance{}, fmt.Errorf("solidity IBC attach Instance: query ICS26Router authority: %w", err) + } + if authority == (common.Address{}) { + return instance{}, fmt.Errorf("solidity IBC attach Instance: ICS26Router has a zero authority") + } + if err := s.requireCode(ctx, "AccessManager", authority); err != nil { + return instance{}, err + } + if err := s.requireAccessManager(ctx, authority); err != nil { + return instance{}, fmt.Errorf( + "solidity IBC attach Instance: authority %s is not an AccessManager: %w", + authority, + err, + ) + } + return instance{AccessManager: authority, Router: routerAddress}, nil +} + +// clientRegistered reports whether clientID is already registered on the router. +// A read failure other than IBCClientNotFound is surfaced as an error. +func (s *setup) clientRegistered(ctx context.Context, router common.Address, clientID string) (bool, error) { + contract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return false, fmt.Errorf("bind ICS26Router: %w", err) + } + _, err = contract.GetClient(&bind.CallOpts{Context: ctx}, clientID) + if err == nil { + return true, nil + } + if isIBCClientNotFound(err) { + return false, nil + } + return false, fmt.Errorf("query ICS26Router client %q: %w", clientID, err) +} + +// gmpAppRegistered reports whether the GMP port is already registered on the +// router. Used to make app deployment re-runnable: AddIBCApp0 reverts on a port +// that is already taken. A missing port (IBCAppNotFound revert) reports false; +// any other read failure (RPC/transport error) is surfaced so it is never +// mistaken for an absent app. +func (s *setup) gmpAppRegistered(ctx context.Context, router common.Address) (bool, error) { + app, err := s.gmpAppAddress(ctx, router) + if err != nil { + if isIBCAppNotFound(err) { + return false, nil + } + return false, fmt.Errorf("query ICS26Router GMP port %q: %w", gmpPortID, err) + } + return app != (common.Address{}), nil +} + +// gmpAppAddress returns the contract registered under the GMP port. It returns +// an IBCAppNotFound revert error when the port is vacant; callers must inspect +// the error with isIBCAppNotFound rather than treating it as a zero address. +func (s *setup) gmpAppAddress(ctx context.Context, router common.Address) (common.Address, error) { + contract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return common.Address{}, fmt.Errorf("bind ICS26Router: %w", err) + } + return contract.GetIBCApp(&bind.CallOpts{Context: ctx}, gmpPortID) +} + +// deployClient deploys an attestation light client and registers it with the +// router under config.ID. It performs a read-only vacancy check first. +func (s *setup) deployClient( + ctx context.Context, + authority Account, + router common.Address, + config attestationClientConfig, +) (client, error) { + config = config.snapshot() + if err := config.validate(); err != nil { + return client{}, fmt.Errorf("solidity IBC deploy Client: %w", err) + } + if err := validateAuthority(authority); err != nil { + return client{}, fmt.Errorf("solidity IBC deploy Client: %w", err) + } + inst, err := s.attachInstance(ctx, router) + if err != nil { + return client{}, fmt.Errorf("solidity IBC deploy Client: %w", err) + } + if authErr := s.requireCanAddCustomClient(ctx, inst, authority.Address()); authErr != nil { + return client{}, fmt.Errorf("solidity IBC deploy Client: authority cannot register it: %w", authErr) + } + if vacantErr := s.verifyClientVacant(ctx, inst, config.ID); vacantErr != nil { + return client{}, fmt.Errorf("solidity IBC deploy Client: %w", vacantErr) + } + + clientAddress, tx, err := s.send( + ctx, + authority, + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := attestation.DeployContract( + opts, + s.backend, + config.Attestors, + config.MinRequiredSignatures, + config.InitialHeight, + config.InitialTimestamp, + config.RoleManager, + ) + return address, transaction, deployErr + }, + ) + if err != nil { + return client{}, fmt.Errorf("solidity IBC deploy attestation Client %q: %w", config.ID, err) + } + clientReceipt, err := s.awaitMined(ctx, "deploy attestation Client "+config.ID, tx) + if err != nil { + return client{}, err + } + if deploymentErr := requireDeploymentAddress( + "attestation Client "+config.ID, + clientAddress, + clientReceipt, + ); deploymentErr != nil { + return client{}, deploymentErr + } + + router0, err := ics26router.NewContract(inst.Router, s.backend) + if err != nil { + return client{}, fmt.Errorf("solidity IBC register Client %q: bind ICS26Router: %w", config.ID, err) + } + _, tx, err = s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := router0.AddClient(opts, config.ID, ics26router.IICS02ClientMsgsCounterpartyInfo{ + ClientId: config.CounterpartyClientID, + MerklePrefix: [][]byte{{}}, + }, clientAddress) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return client{}, fmt.Errorf("solidity IBC register Client %q: %w", config.ID, err) + } + if _, err = s.awaitMined(ctx, "register Client "+config.ID, tx); err != nil { + return client{}, err + } + + deployed, err := s.verifyClient(ctx, inst, config.ID, clientAddress, config.CounterpartyClientID) + if err != nil { + return client{}, fmt.Errorf("solidity IBC verify deployed Client %q: %w", config.ID, err) + } + return deployed, nil +} + +// verifyClientVacant proves that a custom Client ID is currently unoccupied. An +// unexpected read failure is not treated as vacancy. +func (s *setup) verifyClientVacant(ctx context.Context, inst instance, clientID string) error { + if !validCustomClientID(clientID) { + return fmt.Errorf("client id %q is not a valid Solidity IBC custom client identifier", clientID) + } + router, err := ics26router.NewContract(inst.Router, s.backend) + if err != nil { + return fmt.Errorf("verify Client %q vacancy: bind ICS26Router: %w", clientID, err) + } + registered, err := router.GetClient(&bind.CallOpts{Context: ctx}, clientID) + if err == nil { + return fmt.Errorf("client %q is already registered at %s", clientID, registered) + } + if isIBCClientNotFound(err) { + return nil + } + return fmt.Errorf("verify Client %q vacancy: query ICS26Router: %w", clientID, err) +} + +// attachClient discovers the light-client address from the router, verifies the +// reciprocal counterparty ID and EVM empty Merkle prefix, and confirms the +// registered contract exposes a valid attestation set. +func (s *setup) attachClient( + ctx context.Context, + router common.Address, + clientID string, + counterpartyClientID string, +) (client, error) { + inst, err := s.attachInstance(ctx, router) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: %w", clientID, err) + } + return s.verifyClient(ctx, inst, clientID, common.Address{}, counterpartyClientID) +} + +func (s *setup) verifyClient( + ctx context.Context, + inst instance, + clientID string, + expectedAddress common.Address, + counterpartyClientID string, +) (client, error) { + if clientID == "" { + return client{}, fmt.Errorf("solidity IBC attach Client: empty client id") + } + if counterpartyClientID == "" { + return client{}, fmt.Errorf("solidity IBC attach Client %q: empty counterparty client id", clientID) + } + router, err := ics26router.NewContract(inst.Router, s.backend) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: bind ICS26Router: %w", clientID, err) + } + registered, err := router.GetClient(&bind.CallOpts{Context: ctx}, clientID) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: query router client: %w", clientID, err) + } + if expectedAddress != (common.Address{}) && registered != expectedAddress { + return client{}, fmt.Errorf( + "solidity IBC attach Client %q: router has address %s, want %s", + clientID, + registered, + expectedAddress, + ) + } + if registered == (common.Address{}) { + return client{}, fmt.Errorf("solidity IBC attach Client %q: router returned a zero contract address", clientID) + } + if codeErr := s.requireCode(ctx, "attestation Client "+clientID, registered); codeErr != nil { + return client{}, codeErr + } + counterparty, err := router.GetCounterparty(&bind.CallOpts{Context: ctx}, clientID) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: query counterparty: %w", clientID, err) + } + if counterparty.ClientId != counterpartyClientID { + return client{}, fmt.Errorf( + "solidity IBC attach Client %q: counterparty id is %q, want %q", + clientID, + counterparty.ClientId, + counterpartyClientID, + ) + } + if len(counterparty.MerklePrefix) != 1 || len(counterparty.MerklePrefix[0]) != 0 { + return client{}, fmt.Errorf( + "solidity IBC attach Client %q: counterparty Merkle prefix is not the EVM empty prefix", + clientID, + ) + } + + lightClient, err := attestation.NewContract(registered, s.backend) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: bind attestation contract: %w", clientID, err) + } + set, err := lightClient.GetAttestationSet(&bind.CallOpts{Context: ctx}) + if err != nil { + return client{}, fmt.Errorf("solidity IBC attach Client %q: query attestation set: %w", clientID, err) + } + if len(set.AttestorAddresses) == 0 || set.MinRequiredSigs == 0 || + int(set.MinRequiredSigs) > len(set.AttestorAddresses) { + return client{}, fmt.Errorf("solidity IBC attach Client %q: invalid attestation set", clientID) + } + return client{ + ID: clientID, + Address: registered, + CounterpartyClientID: counterpartyClientID, + Attestors: slices.Clone(set.AttestorAddresses), + MinRequiredSignatures: set.MinRequiredSigs, + }, nil +} + +// deployGMPApp deploys the ICS27 GMP application (account logic, GMP +// implementation, and initialized ERC1967 proxy) and registers it with the ICS26 +// router under the GMP port. authority must be the router's AccessManager admin. +func (s *setup) deployGMPApp( + ctx context.Context, + authority Account, + router common.Address, + accessManager common.Address, +) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: %w", err) + } + + accountLogic, err := s.deploy(ctx, authority, "ICS27Account logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics27account.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + gmpLogic, err := s.deploy(ctx, authority, "ICS27GMP logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ics27gmp.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + gmpABI, err := ics27gmp.ContractMetaData.GetAbi() + if err != nil || gmpABI == nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: read ICS27GMP ABI: %w", err) + } + initialization, err := gmpABI.Pack("initialize", router, accountLogic, accessManager) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy GMP: encode initialization: %w", err) + } + + gmpProxy, err := s.deploy(ctx, authority, "ICS27GMP proxy", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract(opts, s.backend, gmpLogic, initialization) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + routerContract, err := ics26router.NewContract(router, s.backend) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC register GMP: bind ICS26Router: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := routerContract.AddIBCApp0(opts, gmpPortID, gmpProxy) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC register GMP port %q: %w", gmpPortID, err) + } + if _, err := s.awaitMined(ctx, "register GMP port", tx); err != nil { + return common.Address{}, err + } + return gmpProxy, nil +} + +// deployIFT deploys an upgradeable IFT token (implementation plus an initialized +// ERC1967 proxy). The token is Ownable: owner receives mint and +// registerIFTBridge authority and must be the deploying authority's address so +// the same account can administer it. gmp is the ICS27 GMP the token sends +// through. +func (s *setup) deployIFT( + ctx context.Context, + authority Account, + owner common.Address, + gmp common.Address, + name string, + symbol string, +) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: %w", err) + } + + iftLogic, err := s.deploy(ctx, authority, "IFT logic", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := ift.DeployContract(opts, s.backend) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + + iftABI, err := ift.ContractMetaData.GetAbi() + if err != nil || iftABI == nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: read IFT ABI: %w", err) + } + initialization, err := iftABI.Pack("initialize", owner, name, symbol, gmp) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy IFT: encode initialization: %w", err) + } + + iftProxy, err := s.deploy(ctx, authority, "IFT proxy", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := erc1967proxy.DeployContract(opts, s.backend, iftLogic, initialization) + return address, transaction, deployErr + }) + if err != nil { + return common.Address{}, err + } + return iftProxy, nil +} + +// deploySendCallConstructor deploys the EVM IFT send-call constructor used to +// build counterparty mint calldata during registerIFTBridge. +func (s *setup) deploySendCallConstructor(ctx context.Context, authority Account) (common.Address, error) { + if err := validateAuthority(authority); err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy send-call constructor: %w", err) + } + constructorABI, bytecode, err := loadSendCallConstructorArtifact() + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy send-call constructor: %w", err) + } + return s.deploy(ctx, authority, "EVMIFTSendCallConstructor", + func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + address, transaction, _, deployErr := bind.DeployContract(opts, constructorABI, bytecode, s.backend) + return address, transaction, deployErr + }) +} + +// registerIFTBridge points the IFT's bridge for clientID at the counterparty IFT +// address and send-call constructor. authority must be the IFT's AccessManager +// admin because registerIFTBridge is access-restricted. +func (s *setup) registerIFTBridge( + ctx context.Context, + authority Account, + iftAddress common.Address, + clientID string, + counterpartyIFT string, + sendCallConstructor common.Address, +) error { + contract, err := ift.NewContract(iftAddress, s.backend) + if err != nil { + return fmt.Errorf("solidity IBC register IFT bridge: bind IFT: %w", err) + } + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := contract.RegisterIFTBridge(opts, clientID, counterpartyIFT, sendCallConstructor) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return fmt.Errorf("solidity IBC register IFT bridge for client %q: %w", clientID, err) + } + _, err = s.awaitMined(ctx, "register IFT bridge "+clientID, tx) + return err +} + +// authorizePublicRelay opens the ICS26 router's relayer-restricted functions to +// any caller by assigning them the public role. This lets a non-admin Relayer +// signer submit recvPacket/ackPacket/timeoutPacket/updateClient. authority must +// be the router's AccessManager admin. +func (s *setup) authorizePublicRelay( + ctx context.Context, + authority Account, + accessManager common.Address, + router common.Address, +) error { + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil || routerABI == nil { + return fmt.Errorf("solidity IBC authorize public relay: read ICS26Router ABI: %w", err) + } + selectors := make([][4]byte, 0, len(ics26RelayerSelectorNames)) + for _, name := range ics26RelayerSelectorNames { + method, ok := routerABI.Methods[name] + if !ok { + return fmt.Errorf("solidity IBC authorize public relay: ICS26Router ABI has no %q method", name) + } + var selector [4]byte + copy(selector[:], method.ID) + selectors = append(selectors, selector) + } + + accessABI, _, err := loadAccessManagerArtifact() + if err != nil { + return fmt.Errorf("solidity IBC authorize public relay: %w", err) + } + access := bind.NewBoundContract(accessManager, accessABI, s.backend, s.backend, s.backend) + _, tx, err := s.send(ctx, authority, func(opts *bind.TransactOpts) (common.Address, *types.Transaction, error) { + transaction, sendErr := access.Transact(opts, "setTargetFunctionRole", router, selectors, publicRole) + return common.Address{}, transaction, sendErr + }) + if err != nil { + return fmt.Errorf("solidity IBC authorize public relay: %w", err) + } + _, err = s.awaitMined(ctx, "authorize public relay", tx) + return err +} + +func (s *setup) send( + ctx context.Context, + authority Account, + fn func(*bind.TransactOpts) (common.Address, *types.Transaction, error), +) (common.Address, *types.Transaction, error) { + opts, err := authority.TransactOpts(s.chainID) + if err != nil { + return common.Address{}, nil, err + } + opts.Context = ctx + opts.NoSend = true + address, tx, err := fn(opts) + if err != nil { + return common.Address{}, nil, err + } + if tx == nil { + return common.Address{}, nil, fmt.Errorf("transaction was not constructed") + } + if err := s.backend.SendTransaction(ctx, tx); err != nil { + return address, tx, fmt.Errorf("broadcast transaction %s: %w", tx.Hash(), err) + } + return address, tx, nil +} + +func (s *setup) awaitMined(ctx context.Context, stage string, tx *types.Transaction) (*types.Receipt, error) { + if tx == nil { + return nil, fmt.Errorf("solidity IBC %s: transaction was not returned", stage) + } + receipt, err := bind.WaitMined(ctx, s.backend, tx) + if err != nil { + return nil, fmt.Errorf("solidity IBC %s: wait for transaction %s: %w", stage, tx.Hash(), err) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return receipt, fmt.Errorf("solidity IBC %s: transaction %s reverted", stage, tx.Hash()) + } + return receipt, nil +} + +// deploy sends a contract-creation transaction, waits for it to mine, and +// verifies the deployed address matches the prediction. +func (s *setup) deploy( + ctx context.Context, + authority Account, + label string, + fn func(*bind.TransactOpts) (common.Address, *types.Transaction, error), +) (common.Address, error) { + address, tx, err := s.send(ctx, authority, fn) + if err != nil { + return common.Address{}, fmt.Errorf("solidity IBC deploy %s: %w", label, err) + } + receipt, err := s.awaitMined(ctx, "deploy "+label, tx) + if err != nil { + return common.Address{}, err + } + if err := requireDeploymentAddress(label, address, receipt); err != nil { + return common.Address{}, err + } + return address, nil +} + +func isIBCClientNotFound(err error) bool { + return evmrevert.HasSelector(err, "IBCClientNotFound(string)") +} + +func isIBCAppNotFound(err error) bool { + return evmrevert.HasSelector(err, "IBCAppNotFound(string)") +} + +func (s *setup) requireCode(ctx context.Context, label string, address common.Address) error { + code, err := s.backend.CodeAt(ctx, address, nil) + if err != nil { + return fmt.Errorf("solidity IBC verify %s %s code: %w", label, address, err) + } + if len(code) == 0 { + return fmt.Errorf("solidity IBC verify %s %s: no contract code", label, address) + } + return nil +} + +func (s *setup) requireAdmin(ctx context.Context, accessManager, authority common.Address) error { + accessABI, _, err := loadAccessManagerArtifact() + if err != nil { + return err + } + contract := bind.NewBoundContract(accessManager, accessABI, s.backend, nil, nil) + var output []any + if err := contract.Call(&bind.CallOpts{Context: ctx}, &output, "hasRole", uint64(0), authority); err != nil { + return fmt.Errorf("query admin role: %w", err) + } + if len(output) != 2 { + return fmt.Errorf("query admin role returned %d values, want 2", len(output)) + } + isAdmin := *abi.ConvertType(output[0], new(bool)).(*bool) + if !isAdmin { + return fmt.Errorf("address %s is not an AccessManager admin", authority) + } + return nil +} + +func (s *setup) requireAccessManager(ctx context.Context, address common.Address) error { + accessABI, _, err := loadAccessManagerArtifact() + if err != nil { + return err + } + contract := bind.NewBoundContract(address, accessABI, s.backend, nil, nil) + var output []any + if err := contract.Call(&bind.CallOpts{Context: ctx}, &output, "ADMIN_ROLE"); err != nil { + return fmt.Errorf("query ADMIN_ROLE: %w", err) + } + if len(output) != 1 { + return fmt.Errorf("query ADMIN_ROLE returned %d values, want 1", len(output)) + } + adminRole := *abi.ConvertType(output[0], new(uint64)).(*uint64) + if adminRole != 0 { + return fmt.Errorf("ADMIN_ROLE is %d, want 0", adminRole) + } + return nil +} + +func (s *setup) requireCanAddCustomClient(ctx context.Context, inst instance, authority common.Address) error { + routerABI, err := ics26router.ContractMetaData.GetAbi() + if err != nil { + return fmt.Errorf("read ICS26Router ABI: %w", err) + } + if routerABI == nil { + return fmt.Errorf("upstream ICS26Router binding has no ABI") + } + selector, err := customAddClientSelector(*routerABI) + if err != nil { + return err + } + + accessABI, _, err := loadAccessManagerArtifact() + if err != nil { + return err + } + contract := bind.NewBoundContract(inst.AccessManager, accessABI, s.backend, nil, nil) + var output []any + if err := contract.Call( + &bind.CallOpts{Context: ctx}, + &output, + "canCall", + authority, + inst.Router, + selector, + ); err != nil { + return fmt.Errorf("query addClient permission: %w", err) + } + if len(output) != 2 { + return fmt.Errorf("query addClient permission returned %d values, want 2", len(output)) + } + immediate := *abi.ConvertType(output[0], new(bool)).(*bool) + delay := *abi.ConvertType(output[1], new(uint32)).(*uint32) + if !immediate { + return fmt.Errorf("address %s cannot call custom addClient immediately (delay %d)", authority, delay) + } + return nil +} + +func customAddClientSelector(routerABI abi.ABI) ([4]byte, error) { + for _, method := range routerABI.Methods { + if method.RawName == "addClient" && len(method.Inputs) == 3 { + var selector [4]byte + copy(selector[:], method.ID) + return selector, nil + } + } + return [4]byte{}, fmt.Errorf("upstream ICS26Router ABI has no custom addClient overload") +} + +func validateAuthority(authority Account) error { + if authority.Address() == (common.Address{}) { + return fmt.Errorf("authority is required") + } + return nil +} + +func requireDeploymentAddress(label string, predicted common.Address, receipt *types.Receipt) error { + if receipt == nil { + return fmt.Errorf("solidity IBC deploy %s: no mined receipt", label) + } + if receipt.ContractAddress != predicted { + return fmt.Errorf( + "solidity IBC deploy %s: receipt contract address is %s, predicted %s", + label, + receipt.ContractAddress, + predicted, + ) + } + return nil +} diff --git a/link/internal/deploy/setup_test.go b/link/internal/deploy/setup_test.go new file mode 100644 index 000000000..3f4f2e878 --- /dev/null +++ b/link/internal/deploy/setup_test.go @@ -0,0 +1,184 @@ +package deploy + +import ( + "context" + "math/big" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/stretchr/testify/require" +) + +func newTestAccount() (Account, error) { + key, err := crypto.GenerateKey() + if err != nil { + return Account{}, err + } + return AccountFromECDSA(key), nil +} + +func ether(amount int64) *big.Int { + return new(big.Int).Mul(big.NewInt(amount), big.NewInt(1_000_000_000_000_000_000)) +} + +func startMining(t *testing.T, backend *simulated.Backend) { + t.Helper() + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + backend.Commit() + } + } + }() + t.Cleanup(func() { + close(stop) + wg.Wait() + require.NoError(t, backend.Close()) + }) +} + +// TestSetupFullFlowSimulated exercises the ported per-chain executor end to end +// on an in-process simulated backend: instance, apps, client, authorize, and +// bridge registration. It requires no docker. +func TestSetupFullFlowSimulated(t *testing.T) { + deployer, err := newTestAccount() + require.NoError(t, err) + attestor1, err := newTestAccount() + require.NoError(t, err) + attestor2, err := newTestAccount() + require.NoError(t, err) + + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + deployer.Address(): {Balance: ether(100)}, + }) + startMining(t, backend) + + setup, err := newSetup(backend.Client(), big.NewInt(1337)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + instance, err := setup.deployInstance(ctx, deployer) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, instance.Router) + require.NotEqual(t, common.Address{}, instance.AccessManager) + + // Re-attaching resolves the same instance. + attached, err := setup.attachInstance(ctx, instance.Router) + require.NoError(t, err) + require.Equal(t, instance, attached) + + // Apps. + gmpRegistered, err := setup.gmpAppRegistered(ctx, instance.Router) + require.NoError(t, err) + require.False(t, gmpRegistered) + + gmp, err := setup.deployGMPApp(ctx, deployer, instance.Router, instance.AccessManager) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, gmp) + + gmpRegistered, err = setup.gmpAppRegistered(ctx, instance.Router) + require.NoError(t, err) + require.True(t, gmpRegistered, "GMP port must be detected as registered after deploy") + + ift, err := setup.deployIFT(ctx, deployer, deployer.Address(), gmp, "IBC Fungible Token", "IFT") + require.NoError(t, err) + require.NotEqual(t, common.Address{}, ift) + + sendCall, err := setup.deploySendCallConstructor(ctx, deployer) + require.NoError(t, err) + require.NotEqual(t, common.Address{}, sendCall) + + // Client. + registered, err := setup.clientRegistered(ctx, instance.Router, "eth-client-0") + require.NoError(t, err) + require.False(t, registered) + + client, err := setup.deployClient(ctx, deployer, instance.Router, attestationClientConfig{ + ID: "eth-client-0", + CounterpartyClientID: "eth-client-1", + Attestors: []common.Address{attestor1.Address(), attestor2.Address()}, + MinRequiredSignatures: 2, + InitialHeight: 1, + InitialTimestamp: 1_700_000_000, + }) + require.NoError(t, err) + require.Equal(t, "eth-client-0", client.ID) + require.Equal(t, []common.Address{attestor1.Address(), attestor2.Address()}, client.Attestors) + require.Equal(t, uint8(2), client.MinRequiredSignatures) + + registered, err = setup.clientRegistered(ctx, instance.Router, "eth-client-0") + require.NoError(t, err) + require.True(t, registered) + + // Authorize public relay and register an IFT bridge (self-referential is fine + // for a single-backend smoke test). + require.NoError(t, setup.authorizePublicRelay(ctx, deployer, instance.AccessManager, instance.Router)) + require.NoError(t, setup.registerIFTBridge(ctx, deployer, ift, "eth-client-0", ift.Hex(), sendCall)) +} + +// TestOccupiedClientTrustPolicyMismatch deploys a 1-of-1 attestation client, then +// reads it back and verifies the occupied-client trust-policy check accepts a +// matching config and rejects a divergent one (2-of-2), with a precise diff. +func TestOccupiedClientTrustPolicyMismatch(t *testing.T) { + deployer, err := newTestAccount() + require.NoError(t, err) + attestor1, err := newTestAccount() + require.NoError(t, err) + attestor2, err := newTestAccount() + require.NoError(t, err) + + backend := simulated.NewBackend(gethtypes.GenesisAlloc{ + deployer.Address(): {Balance: ether(100)}, + }) + startMining(t, backend) + + setup, err := newSetup(backend.Client(), big.NewInt(1337)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + instance, err := setup.deployInstance(ctx, deployer) + require.NoError(t, err) + + _, err = setup.deployClient(ctx, deployer, instance.Router, attestationClientConfig{ + ID: "eth-client-0", + CounterpartyClientID: "eth-client-1", + Attestors: []common.Address{attestor1.Address()}, + MinRequiredSignatures: 1, + InitialHeight: 1, + InitialTimestamp: 1_700_000_000, + }) + require.NoError(t, err) + + onChain, err := setup.attachClient(ctx, instance.Router, "eth-client-0", "eth-client-1") + require.NoError(t, err) + + // Matching config: 1-of-1 with the same attestor. + require.NoError(t, requireTrustPolicyMatch( + "1337", "eth-client-0", onChain, []common.Address{attestor1.Address()}, 1)) + + // Divergent config: 2-of-2 with an extra attestor. Precise diff error. + err = requireTrustPolicyMatch( + "1337", "eth-client-0", onChain, + []common.Address{attestor1.Address(), attestor2.Address()}, 2) + require.ErrorContains(t, err, "does not match config") + require.ErrorContains(t, err, "on-chain 1-of-1") + require.ErrorContains(t, err, "config 2-of-2") +} diff --git a/link/internal/deploy/types.go b/link/internal/deploy/types.go new file mode 100644 index 000000000..aa5aa96d6 --- /dev/null +++ b/link/internal/deploy/types.go @@ -0,0 +1,119 @@ +// Package deploy orchestrates one-step Solidity IBC contract deployment and +// connection for a configured client pair: it deploys the AccessManager + +// ICS26Router instance, the ICS27 GMP and IFT apps, and the attestation light +// clients, then (for connect) cross-wires the IFT bridges. It must not import +// e2e code, so the forge artifacts it needs are embedded independently. +package deploy + +import ( + "fmt" + "slices" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// instance is one initialized ICS26 router installation. The router address is +// the ERC1967 proxy used by clients and relayers; AccessManager controls its +// restricted operations. +type instance struct { + AccessManager common.Address + Router common.Address +} + +// client is an attestation light client registered with one Instance. +type client struct { + ID string + Address common.Address + CounterpartyClientID string + Attestors []common.Address + MinRequiredSignatures uint8 +} + +// attestationClientConfig contains the immutable constructor inputs for an +// attestation light client. A zero RoleManager intentionally permits anyone to +// submit proofs, matching Solidity IBC's constructor semantics. +type attestationClientConfig struct { + ID string + CounterpartyClientID string + Attestors []common.Address + MinRequiredSignatures uint8 + InitialHeight uint64 + InitialTimestamp uint64 + RoleManager common.Address +} + +func (c attestationClientConfig) snapshot() attestationClientConfig { + c.Attestors = slices.Clone(c.Attestors) + return c +} + +// validateStatic checks the config-derived fields that are known before any +// chain is dialed: identifiers, the attestor set, and the signature threshold. +// It deliberately excludes InitialHeight/InitialTimestamp, which are read from a +// live counterparty header at run time. Preflight uses this so a misconfigured +// attestor set fails before any transaction is broadcast. +func (c attestationClientConfig) validateStatic() error { + if !validCustomClientID(c.ID) { + return fmt.Errorf("client id %q is not a valid Solidity IBC custom client identifier", c.ID) + } + if c.CounterpartyClientID == "" { + return fmt.Errorf("client %q has an empty counterparty client id", c.ID) + } + if len(c.Attestors) == 0 { + return fmt.Errorf("client %q has no attestors", c.ID) + } + seen := make(map[common.Address]struct{}, len(c.Attestors)) + for _, address := range c.Attestors { + if address == (common.Address{}) { + return fmt.Errorf("client %q has a zero attestor address", c.ID) + } + if _, duplicate := seen[address]; duplicate { + return fmt.Errorf("client %q repeats attestor %s", c.ID, address) + } + seen[address] = struct{}{} + } + if c.MinRequiredSignatures == 0 || int(c.MinRequiredSignatures) > len(c.Attestors) { + return fmt.Errorf( + "client %q requires %d signatures from %d attestors", + c.ID, + c.MinRequiredSignatures, + len(c.Attestors), + ) + } + return nil +} + +func (c attestationClientConfig) validate() error { + if err := c.validateStatic(); err != nil { + return err + } + if c.InitialHeight == 0 { + return fmt.Errorf("client %q has zero initial height", c.ID) + } + if c.InitialTimestamp == 0 { + return fmt.Errorf("client %q has zero initial timestamp", c.ID) + } + return nil +} + +// validCustomClientID mirrors Solidity IBC v3's IBCIdentifiers validation. The +// access-controlled addClient overload rejects generated "client-" identifiers +// and accepts only these explicit custom identifiers. +func validCustomClientID(id string) bool { + if len(id) < 4 || len(id) > 128 || strings.HasPrefix(id, "channel-") || strings.HasPrefix(id, "client-") { + return false + } + for _, b := range []byte(id) { + if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' { + continue + } + switch b { + case '.', '_', '+', '-', '#', '[', ']', '<', '>': + continue + default: + return false + } + } + return true +} diff --git a/link/internal/deploy/writeconfig.go b/link/internal/deploy/writeconfig.go new file mode 100644 index 000000000..d890c2e49 --- /dev/null +++ b/link/internal/deploy/writeconfig.go @@ -0,0 +1,220 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + + yaml "gopkg.in/yaml.v3" +) + +// yamlStrTag is the explicit string tag applied to patched scalar nodes so +// address values are always emitted as quoted strings. +const yamlStrTag = "!!str" + +// PatchConfigFile surgically writes the deployed router addresses back into the +// config file at path, editing the original YAML text rather than re-serializing +// the loaded (env-expanded) config struct. It sets only: +// - chains[].evm.ics26Router +// - attestor.attestations[].evm.routerAddress +// +// for chains present in result. Comments, key ordering, anchors, and unexpanded +// ${ENV} placeholders (DB passwords, RPC tokens) are preserved because only the +// targeted scalar nodes are touched. It returns the human-readable changes made. +func PatchConfigFile(path string, result *Result) ([]string, error) { + if result == nil { + return nil, nil + } + + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + + var doc yaml.Node + if err = yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + root := documentRoot(&doc) + if root == nil { + return nil, fmt.Errorf("config %s: empty or non-mapping document", path) + } + + var changes []string + for _, chainResult := range result.Chains { + if chainResult.ICS26Router == "" { + continue + } + + // chains[].evm.ics26Router + if chains := mappingValue(root, "chains"); chains != nil { + for _, chain := range chains.Content { + if scalarValue(chain, "chainId") != chainResult.ChainID { + continue + } + evm := mappingValue(chain, "evm") + if evm == nil { + continue + } + if setScalar(evm, "ics26Router", chainResult.ICS26Router) { + changes = append(changes, fmt.Sprintf( + "chains[].evm.ics26Router = %s (chain %q)", chainResult.ICS26Router, chainResult.ChainID)) + } + } + } + + // attestor.attestations[].evm.routerAddress + if attestor := mappingValue(root, "attestor"); attestor != nil { + if attestations := mappingValue(attestor, "attestations"); attestations != nil { + for _, att := range attestations.Content { + if scalarValue(att, "chainId") != chainResult.ChainID { + continue + } + evm := mappingValue(att, "evm") + if evm == nil { + continue + } + if setScalar(evm, "routerAddress", chainResult.ICS26Router) { + changes = append(changes, fmt.Sprintf( + "attestor.attestations[].evm.routerAddress = %s (chain %q)", + chainResult.ICS26Router, chainResult.ChainID)) + } + } + } + } + } + + if len(changes) == 0 { + return nil, nil + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return nil, fmt.Errorf("re-serialize config %s: %w", path, err) + } + if err := writeFileAtomic(path, out); err != nil { + return nil, fmt.Errorf("write config %s: %w", path, err) + } + return changes, nil +} + +// writeFileAtomic writes data to path via a temp file in the same directory +// followed by an atomic rename, so a crash mid-write cannot leave a truncated +// config. The original file's permission bits are preserved: they are read via +// a required os.Stat, whose failure aborts the write rather than silently +// substituting some other mode. +// +// Symlinks are resolved first (via filepath.EvalSymlinks) and the temp+rename +// replacement is performed against the resolved target: renaming over the link +// itself would replace it with a regular file and leave the real target stale. +// Patching the target in place keeps the symlink pointing at the updated file. +// A non-symlink path resolves to itself, so the behavior is identical. +func writeFileAtomic(path string, data []byte) error { + // Resolve any symlink so we replace the real target, not the link node. If + // resolution fails (e.g. broken link), fall back to the original path. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat config %s: %w", path, err) + } + mode := info.Mode().Perm() + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + + // Best-effort cleanup: on any failure before the rename, remove the temp file. + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, mode); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + + cleanup = false + return nil +} + +// documentRoot returns the top-level mapping node of a parsed YAML document. +func documentRoot(doc *yaml.Node) *yaml.Node { + node := doc + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + return nil + } + node = node.Content[0] + } + if node.Kind != yaml.MappingNode { + return nil + } + return node +} + +// mappingValue returns the value node for key in a mapping node, or nil. +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// scalarValue returns the scalar string value for key in a mapping node, or "". +func scalarValue(mapping *yaml.Node, key string) string { + value := mappingValue(mapping, key) + if value == nil { + return "" + } + return value.Value +} + +// setScalar sets key to value in a mapping node, appending the key if absent. It +// reports whether the file content changed (i.e. the value differed or the key +// was added). +func setScalar(mapping *yaml.Node, key, value string) bool { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return false + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value != key { + continue + } + valueNode := mapping.Content[i+1] + if valueNode.Value == value { + return false + } + valueNode.Kind = yaml.ScalarNode + valueNode.Tag = yamlStrTag + valueNode.Value = value + return true + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Value: value}, + ) + return true +} diff --git a/link/internal/metrics/metrics.go b/link/internal/metrics/metrics.go new file mode 100644 index 000000000..e04879b77 --- /dev/null +++ b/link/internal/metrics/metrics.go @@ -0,0 +1,231 @@ +// Package metrics owns the Prometheus registry and the daemon's metric +// definitions. It is injected via bootstrap; a nil *Metrics is a valid no-op +// recorder so instrumented code needs no branching when metrics are disabled. +package metrics + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const namespace = "ibclink" + +// Metric subsystems (the second segment of every metric name). +const ( + subsystemAttestor = "attestor" + subsystemRPC = "rpc" + subsystemRelayer = "relayer" +) + +// labelChainID is the shared "chain_id" label key on relayer metrics. +const labelChainID = "chain_id" + +// Attestation method labels. +const ( + MethodState = "state" + MethodPacket = "packet" + MethodHeight = "height" +) + +// Relay packet terminal-outcome labels for the relayer_packets_total counter. +const ( + OutcomeCompletedAck = "completed_ack" + OutcomeCompletedTimeout = "completed_timeout" + OutcomeErrorAck = "error_ack" + OutcomeFailed = "failed" +) + +// Metrics owns the Prometheus registry and collectors shared across the daemon. +type Metrics struct { + registry *prometheus.Registry + + attestationRequests *prometheus.CounterVec + attestationDuration *prometheus.HistogramVec + + rpcRequests *prometheus.CounterVec + rpcDuration *prometheus.HistogramVec + + relayerPackets *prometheus.CounterVec + relayerTxSubmissions *prometheus.CounterVec + relayerTxGasCost *prometheus.CounterVec + relayerSignerBalance *prometheus.GaugeVec + relayerLowGasAlert *prometheus.GaugeVec +} + +// New builds a Metrics with a fresh registry, the standard Go/process +// collectors, and the daemon's own collectors registered. +func New() *Metrics { + reg := prometheus.NewRegistry() + reg.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + m := &Metrics{ + registry: reg, + attestationRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystemAttestor, + Name: "requests_total", + Help: "Attestation requests handled, by attestor, method, and outcome.", + }, []string{"attestor", "method", "outcome"}), + attestationDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystemAttestor, + Name: "request_duration_seconds", + Help: "Attestation request duration in seconds, by attestor and method.", + Buckets: prometheus.DefBuckets, + }, []string{"attestor", "method"}), + rpcRequests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystemRPC, + Name: "requests_total", + Help: "Connect RPC requests handled, by procedure and code.", + }, []string{"procedure", "code"}), + rpcDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystemRPC, + Name: "request_duration_seconds", + Help: "Connect RPC request duration in seconds, by procedure.", + Buckets: prometheus.DefBuckets, + }, []string{"procedure"}), + relayerPackets: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystemRelayer, + Name: "packets_total", + Help: "Packets that reached a terminal relay state, by source route and outcome.", + }, []string{"route", "outcome"}), + relayerTxSubmissions: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystemRelayer, + Name: "tx_submissions_total", + Help: "Relay transactions resolved, by chain, tx type, and status.", + }, []string{labelChainID, "tx_type", "status"}), + relayerTxGasCost: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystemRelayer, + Name: "tx_gas_cost_wei_total", + Help: "Total gas cost of resolved relay transactions in wei, by chain and tx type.", + }, []string{labelChainID, "tx_type"}), + relayerSignerBalance: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: subsystemRelayer, + Name: "signer_balance_wei", + Help: "Relayer signer account balance in wei, by chain. Carried as a float64, " + + "so balances above ~2^53 wei lose exact-integer precision (ample for alerting).", + }, []string{labelChainID}), + relayerLowGasAlert: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: subsystemRelayer, + Name: "low_gas_alert", + Help: "Low-gas alert severity for the relayer signer, by chain: " + + "0 = ok, 1 = warning (balance <= warningThreshold), 2 = critical (balance <= criticalThreshold). " + + "Present only for chains with configured gasAlertThresholds.", + }, []string{labelChainID}), + } + + reg.MustRegister( + m.attestationRequests, + m.attestationDuration, + m.rpcRequests, + m.rpcDuration, + m.relayerPackets, + m.relayerTxSubmissions, + m.relayerTxGasCost, + m.relayerSignerBalance, + m.relayerLowGasAlert, + ) + + return m +} + +// ObserveAttestation records one attestation request's outcome and duration, +// labeled by the serving attestor. Safe to call on a nil *Metrics. +func (m *Metrics) ObserveAttestation(attestor, method string, dur time.Duration, err error) { + if m == nil { + return + } + + outcome := "ok" + if err != nil { + outcome = "error" + } + + m.attestationRequests.WithLabelValues(attestor, method, outcome).Inc() + m.attestationDuration.WithLabelValues(attestor, method).Observe(dur.Seconds()) +} + +// ObserveRPC records one Connect RPC's code and duration. Safe to call on a nil +// *Metrics. +func (m *Metrics) ObserveRPC(procedure, code string, dur time.Duration) { + if m == nil { + return + } + + m.rpcRequests.WithLabelValues(procedure, code).Inc() + m.rpcDuration.WithLabelValues(procedure).Observe(dur.Seconds()) +} + +// IncRelayerPacket counts one packet reaching a terminal relay state, labeled +// by its source route ("chainID/clientID") and terminal outcome. Safe to call on +// a nil *Metrics. +func (m *Metrics) IncRelayerPacket(route, outcome string) { + if m == nil { + return + } + + m.relayerPackets.WithLabelValues(route, outcome).Inc() +} + +// ObserveTxSubmission records one resolved relay tx by chain, tx type, and +// status, and adds its gas cost (in wei) when known. Gas is unknown for txs that +// expired without ever landing, so gasKnown is false and no gas is added. Safe to +// call on a nil *Metrics. +func (m *Metrics) ObserveTxSubmission(chainID, txType, status string, gasCostWei float64, gasKnown bool) { + if m == nil { + return + } + + m.relayerTxSubmissions.WithLabelValues(chainID, txType, status).Inc() + if gasKnown { + m.relayerTxGasCost.WithLabelValues(chainID, txType).Add(gasCostWei) + } +} + +// SetRelayerSignerBalanceWei records the relayer signer's current balance (in +// wei) on a chain. Reported whenever a balance read succeeds, independent of +// whether alert thresholds are configured. Safe to call on a nil *Metrics. +func (m *Metrics) SetRelayerSignerBalanceWei(chainID string, balanceWei float64) { + if m == nil { + return + } + + m.relayerSignerBalance.WithLabelValues(chainID).Set(balanceWei) +} + +// SetRelayerLowGasAlert records the low-gas alert severity for a chain's signer +// (0 = ok, 1 = warning, 2 = critical); see the metric help for the encoding. Set +// only for chains with configured thresholds. Safe to call on a nil *Metrics. +func (m *Metrics) SetRelayerLowGasAlert(chainID string, severity float64) { + if m == nil { + return + } + + m.relayerLowGasAlert.WithLabelValues(chainID).Set(severity) +} + +// Handler serves the registry in the Prometheus text exposition format. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) +} + +// Registry exposes the underlying registry for gathering: bootstrap serves it +// via promhttp (see Handler), and tests gather against it with testutil. +// Subsystems record through this package's own methods, not by registering here. +func (m *Metrics) Registry() *prometheus.Registry { + return m.registry +} diff --git a/link/internal/network/tools.go b/link/internal/network/tools.go index cfd280179..8af6a3a95 100644 --- a/link/internal/network/tools.go +++ b/link/internal/network/tools.go @@ -23,8 +23,10 @@ func ValidateListenAddr(raw string) error { return fmt.Errorf("port must be numeric: %w", err) } - if portNum < 1 || portNum > 65535 { - return fmt.Errorf("port must be between 1 and 65535, got %d", portNum) + // Port 0 is permitted: it asks the OS to bind an ephemeral port, which the + // server exposes via Addr() after listening. + if portNum < 0 || portNum > 65535 { + return fmt.Errorf("port must be between 0 and 65535, got %d", portNum) } return nil diff --git a/link/internal/network/tools_test.go b/link/internal/network/tools_test.go index 5e3c0e151..6e7bbdc63 100644 --- a/link/internal/network/tools_test.go +++ b/link/internal/network/tools_test.go @@ -40,6 +40,10 @@ func TestValidateListenAddr(t *testing.T) { name: "host delegated to network stack", raw: "my_service:3000", }, + { + name: "ephemeral port zero", + raw: "127.0.0.1:0", + }, } { t.Run(tt.name, func(t *testing.T) { // ARRANGE @@ -75,15 +79,10 @@ func TestValidateListenAddr(t *testing.T) { raw: "localhost:http", errContains: "port must be numeric", }, - { - name: "port zero", - raw: "localhost:0", - errContains: "port must be between 1 and 65535", - }, { name: "port out of range", raw: "localhost:65536", - errContains: "port must be between 1 and 65535", + errContains: "port must be between 0 and 65535", }, } { t.Run(tt.name, func(t *testing.T) { diff --git a/link/internal/pkg/evmrevert/evmrevert.go b/link/internal/pkg/evmrevert/evmrevert.go new file mode 100644 index 000000000..36c88cde6 --- /dev/null +++ b/link/internal/pkg/evmrevert/evmrevert.go @@ -0,0 +1,24 @@ +// Package evmrevert matches EVM custom-error reverts by their 4-byte selector, +// shared by the deploy re-runnability checks and the status prober so both read +// the same "not found" reverts identically. +package evmrevert + +import ( + "bytes" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" +) + +// HasSelector reports whether err is an EVM revert whose data begins with the +// 4-byte selector of the given Solidity custom-error signature (for example +// "IBCClientNotFound(string)"). A non-revert error, or revert data too short to +// carry a selector, reports false. +func HasSelector(err error, signature string) bool { + revertData, ok := ethclient.RevertErrorData(err) + if !ok || len(revertData) < 4 { + return false + } + + return bytes.Equal(revertData[:4], crypto.Keccak256([]byte(signature))[:4]) +} diff --git a/link/internal/pkg/exitcode/exitcode.go b/link/internal/pkg/exitcode/exitcode.go new file mode 100644 index 000000000..6f09b5f1c --- /dev/null +++ b/link/internal/pkg/exitcode/exitcode.go @@ -0,0 +1,50 @@ +// Package exitcode defines BSD sysexits-style process exit codes for the ibc +// CLI and a typed error that carries an exit code up to main. +package exitcode + +// Sysexits-style exit codes. See sysexits(3). +const ( + // OK successful termination. + OK = 0 + + // ConfigInvalid config is structurally invalid (or unparseable / bad flags). + ConfigInvalid = 64 + + // RPCUnreachable a required RPC/DB endpoint could not be reached. + RPCUnreachable = 65 + + // NotReady the service is not (yet) ready. + NotReady = 69 + + // Internal an internal/unexpected error. + Internal = 70 +) + +// Error carries a process exit code alongside the underlying error. main +// inspects it via errors.As and exits with Code; unwrapped errors default to +// Internal. +type Error struct { + Code int + Err error +} + +func (e Error) Error() string { + if e.Err == nil { + return "" + } + + return e.Err.Error() +} + +func (e Error) Unwrap() error { + return e.Err +} + +// New wraps err with the given exit code. Returns nil if err is nil. +func New(code int, err error) error { + if err == nil { + return nil + } + + return Error{Code: code, Err: err} +} diff --git a/link/internal/pkg/exitcode/exitcode_test.go b/link/internal/pkg/exitcode/exitcode_test.go new file mode 100644 index 000000000..21d773173 --- /dev/null +++ b/link/internal/pkg/exitcode/exitcode_test.go @@ -0,0 +1,37 @@ +package exitcode_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/pkg/exitcode" +) + +func TestNew(t *testing.T) { + t.Run("nilStaysNil", func(t *testing.T) { + require.NoError(t, exitcode.New(exitcode.ConfigInvalid, nil)) + }) + + t.Run("carriesCodeAndUnwraps", func(t *testing.T) { + base := errors.New("boom") + err := exitcode.New(exitcode.RPCUnreachable, base) + + var ec exitcode.Error + require.True(t, errors.As(err, &ec)) + assert.Equal(t, exitcode.RPCUnreachable, ec.Code) + assert.Equal(t, "boom", err.Error()) + assert.ErrorIs(t, err, base) + }) + + t.Run("wrappedStillMatchesAs", func(t *testing.T) { + err := fmt.Errorf("context: %w", exitcode.New(exitcode.Internal, errors.New("inner"))) + + var ec exitcode.Error + require.True(t, errors.As(err, &ec)) + assert.Equal(t, exitcode.Internal, ec.Code) + }) +} diff --git a/link/internal/pkg/mathx/mathx.go b/link/internal/pkg/mathx/mathx.go new file mode 100644 index 000000000..96b65f217 --- /dev/null +++ b/link/internal/pkg/mathx/mathx.go @@ -0,0 +1,11 @@ +// Package mathx holds small numeric helpers shared across packages. +package mathx + +// SaturatingSubU64 returns a-b, clamped to 0 when b >= a. +func SaturatingSubU64(a, b uint64) uint64 { + if b >= a { + return 0 + } + + return a - b +} diff --git a/link/internal/preflight/preflight.go b/link/internal/preflight/preflight.go new file mode 100644 index 000000000..e40cbba8c --- /dev/null +++ b/link/internal/preflight/preflight.go @@ -0,0 +1,119 @@ +// Package preflight probes a structurally-valid config against its live external +// dependencies: it dials each chain's EVM RPC and checks database reachability. +// It lives outside the persistence layer so the store package need not depend on +// chain RPC clients; preflight is the one place that composes store + config + +// ethclient for the `ibc config validate --live` command. +package preflight + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/store" +) + +const liveDialTimeout = 5 * time.Second + +// ValidateConfigLive assumes the config.Config is structurally valid and probes +// external dependencies: it dials each chain's EVM RPC, verifies the node's eth +// chainId matches the configured one, and checks database reachability. Its +// per-target issues do not indicate a structurally invalid config; the caller +// maps them to a distinct exit code. +func ValidateConfigLive(ctx context.Context, cfg config.Config) []config.ValidationIssue { + var errs []config.ValidationIssue + + errs = append(errs, validateChainsLive(ctx, cfg)...) + errs = append(errs, validateDBLive(ctx, cfg)...) + + return errs +} + +func validateChainsLive(ctx context.Context, cfg config.Config) []config.ValidationIssue { + var errs []config.ValidationIssue + + for i, chain := range cfg.Chains { + if chain.EVM == nil { + continue + } + + path := fmt.Sprintf("chains[%d].evm.rpc", i) + + dialCtx, cancel := context.WithTimeout(ctx, liveDialTimeout) + client, err := ethclient.DialContext(dialCtx, chain.EVM.RPC) + if err != nil { + cancel() + errs = append(errs, config.ValidationIssue{ + Path: path, + Message: fmt.Sprintf("rpc unreachable (%s): %v", chain.ChainID, err), + }) + + continue + } + + switch nodeChainID, err := client.ChainID(dialCtx); { + case err != nil: + errs = append(errs, config.ValidationIssue{ + Path: path, + Message: fmt.Sprintf("rpc unreachable (%s): %v", chain.ChainID, err), + }) + case evm.CheckChainID(chain.ChainID, nodeChainID) != nil: + errs = append(errs, config.ValidationIssue{ + Path: fmt.Sprintf("chains[%d].chainId", i), + Message: fmt.Sprintf( + "chain id mismatch: config declares %s but the node at this rpc reports %s", + chain.ChainID, nodeChainID, + ), + }) + } + + client.Close() + cancel() + } + + return errs +} + +// validateDBLive probes database reachability. ValidateConfigLive assumes a +// structurally-valid config, so DB.Type is one of the two known values and (for +// sqlite) the URL is a non-empty, non-":memory:" path — those cases are enforced +// by Config.Validate and are not re-checked here. +func validateDBLive(ctx context.Context, cfg config.Config) []config.ValidationIssue { + switch cfg.DB.Type { + case config.DBTypeSQLite: + if err := checkSQLiteWritable(cfg.DB.URL); err != nil { + return []config.ValidationIssue{{Path: "db.url", Message: fmt.Sprintf("sqlite path not writable: %v", err)}} + } + + case config.DBTypePostgres: + s, err := store.NewStore(ctx, cfg) + if err != nil { + return []config.ValidationIssue{{Path: "db.url", Message: fmt.Sprintf("database unreachable: %v", err)}} + } + // The open+ping already proved reachability; a close failure on the probe + // connection is inconsequential to config validity. + _ = s.Close() + } + + return nil +} + +// checkSQLiteWritable verifies the sqlite database directory exists and is +// writable, without creating the database file itself. +func checkSQLiteWritable(url string) error { + probe, err := os.CreateTemp(filepath.Dir(url), ".ibc-writecheck-*") + if err != nil { + return err + } + + name := probe.Name() + _ = probe.Close() + + return os.Remove(name) +} diff --git a/link/internal/relay/autorelay/monitor.go b/link/internal/relay/autorelay/monitor.go new file mode 100644 index 000000000..c6e8bbc4a --- /dev/null +++ b/link/internal/relay/autorelay/monitor.go @@ -0,0 +1,359 @@ +// Package autorelay discovers packets to relay by scanning source chains for +// SendPacket events and recording them for the relay engine to drive. It runs +// alongside the engine: the engine advances packets already in the store, while +// the monitor keeps the store fed from live chain events for every configured +// route whose autoRelay is enabled. +package autorelay + +import ( + "context" + "log/slog" + "time" + + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/clientkey" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/pkg/mathx" + "github.com/cosmos/ibc/link/internal/store" +) + +const ( + // defaultTick is the scan interval when none is configured. + defaultTick = time.Second + + // defaultLookback is how far back the first scan reaches when no cursor + // exists for a route. + defaultLookback uint64 = 100 + + // defaultMaxScan caps the number of blocks scanned per route per tick so a + // first scan of a long-lived chain cannot fetch an unbounded range; scanning + // resumes from the persisted cursor on the next tick. + defaultMaxScan uint64 = 2000 +) + +// ChainClients resolves a chains.Client by chain id. +type ChainClients interface { + GetClient(chainID string) (chains.Client, error) +} + +// Store is the subset of store.Repository the monitor uses. Cursors are keyed +// per (source chain, source client) so enabling a new route on a chain that is +// already being scanned starts from that route's own lookback rather than +// inheriting an unrelated route's progress. +type Store interface { + GetRelayCursor(ctx context.Context, cursorKey string) (uint64, error) + SetRelayCursor(ctx context.Context, cursorKey string, height uint64) error + CreatePacket(ctx context.Context, input store.CreatePacket) error +} + +// scanUnit is one source client to scan on its source chain. Each unit owns its +// own cursor and finality offset so routes never share scanning progress. +type scanUnit struct { + chainID string + sourceClientID string + destChainID string + lookback uint64 + + // finalityOffset is the source chain's finality offset: the monitor scans + // only up to FinalizedHeight(offset) so a reorg below the finalized head can + // never strand a packet the cursor already advanced past. It comes from the + // recv-side (mirror) client's attestor set, whose counterparty finality is + // exactly this source chain. Offset 0 means scan up to latest. + finalityOffset uint64 +} + +// cursorKey is the per-route relay cursor key "/". +func (u scanUnit) cursorKey() string { + return clientkey.Format(u.chainID, u.sourceClientID) +} + +// Monitor scans configured source chains for SendPacket events and records the +// resulting packets as PENDING. +type Monitor struct { + store Store + clients ChainClients + units []scanUnit + logger *slog.Logger + tick time.Duration + maxScan uint64 +} + +// Params configures a Monitor. +type Params struct { + Store Store + Clients ChainClients + Relayer config.RelayerConfig + Logger *slog.Logger + + // Tick is the scan interval; defaults to 1s. + Tick time.Duration + // MaxScan caps blocks scanned per route per tick; defaults to 2000. + MaxScan uint64 +} + +// New builds a Monitor, resolving one scan unit per enabled route in the relayer +// config. A route is enabled when it is listed in routesToRelay and its +// autoRelay is not explicitly disabled (default true). +func New(p Params) (*Monitor, error) { + switch { + case p.Store == nil: + return nil, errors.New("store is required") + case p.Clients == nil: + return nil, errors.New("chain clients are required") + } + + logger := p.Logger + if logger == nil { + logger = slog.Default() + } + logger = logger.With("component", "relay/autorelay") + + tick := p.Tick + if tick <= 0 { + tick = defaultTick + } + + maxScan := p.MaxScan + if maxScan == 0 { + maxScan = defaultMaxScan + } + + units, err := buildUnits(p.Relayer) + if err != nil { + return nil, err + } + + return &Monitor{ + store: p.Store, + clients: p.Clients, + units: units, + logger: logger, + tick: tick, + maxScan: maxScan, + }, nil +} + +// buildUnits resolves one scan unit per enabled route. Each unit carries the +// source-chain finality offset taken from the recv-side (mirror) client's +// attestor set, whose counterparty finality offset is this source chain's. +func buildUnits(cfg config.RelayerConfig) ([]scanUnit, error) { + units := make([]scanUnit, 0, len(cfg.Routes)) + + for _, rc := range cfg.Routes { + if !autoRelayEnabled(rc.AutoRelay) { + continue + } + + client, ok := cfg.ClientByAlias(rc.SourceClient) + if !ok { + return nil, errors.Errorf("route references unknown client %q", rc.SourceClient) + } + + lookback := rc.AutoRelay.Lookback + if lookback == 0 { + lookback = defaultLookback + } + + units = append(units, scanUnit{ + chainID: client.ChainID, + sourceClientID: client.ClientID, + destChainID: client.CounterpartyChainID, + lookback: lookback, + finalityOffset: sourceFinalityOffset(cfg, client), + }) + } + + return units, nil +} + +// sourceFinalityOffset returns the source chain's finality offset for a route: +// the mirror (counterparty) client's attestor set attests the source chain, so +// its counterpartyChainFinalityOffset is exactly the source-chain finality. +// Offset 0 means scan up to latest. +// +// The mirror entry and its attestor set are guaranteed present here by +// construction: the monitor is built only after engine.New, which rejects any +// relayed route whose mirror entry (or recv-side proof generator) is missing, +// and after buildProofGens, which rejects any client without an attestor set. +func sourceFinalityOffset(cfg config.RelayerConfig, client config.ClientConfig) uint64 { + mirror, _ := cfg.Client(client.CounterpartyChainID, client.CounterpartyClientID) + + return mirror.AttestorSet.CounterpartyChainFinalityOffset +} + +// autoRelayEnabled reports whether a route should be auto-scanned. Absent config +// (Enabled nil) defaults to true; an explicit false disables it. +func autoRelayEnabled(cfg config.AutoRelayConfig) bool { + return cfg.Enabled == nil || *cfg.Enabled +} + +// Run scans on every tick until ctx is canceled. The first scan fires +// immediately. It returns nil once ctx is done. +func (m *Monitor) Run(ctx context.Context) error { + if len(m.units) == 0 { + m.logger.Info("no auto-relay routes configured; monitor idle") + <-ctx.Done() + + return nil + } + + ticker := time.NewTicker(m.tick) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + m.RunOnce(ctx) + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +// RunOnce performs a single scan pass over every configured route. +func (m *Monitor) RunOnce(ctx context.Context) { + for i := range m.units { + if ctx.Err() != nil { + return + } + m.scanUnit(ctx, m.units[i]) + } +} + +// scanUnit scans one route's new blocks once, up to the source chain's finalized +// height, and records every packet belonging to that route. The cursor is only +// advanced across blocks whose packets were all persisted, so a failed insert is +// retried on the next tick instead of being skipped forever. +func (m *Monitor) scanUnit(ctx context.Context, u scanUnit) { + client, err := m.clients.GetClient(u.chainID) + if err != nil { + m.logger.Warn("resolving source chain client", "chainID", u.chainID, "err", err) + + return + } + + // Scan only up to finalized: a block that a reorg later replaces must not be + // crossed by the cursor. Offset 0 resolves to latest (unchanged behavior). + offset := u.finalityOffset + finalized, err := client.FinalizedHeight(ctx, &offset) + if err != nil { + m.logger.Warn("reading finalized height", "chainID", u.chainID, "err", err) + + return + } + + from, err := m.scanFrom(ctx, u, finalized) + if err != nil { + m.logger.Warn("resolving scan cursor", "chainID", u.chainID, "sourceClientID", u.sourceClientID, "err", err) + + return + } + if from > finalized { + return + } + + to := finalized + if to-from+1 > m.maxScan { + to = from + m.maxScan - 1 + } + + events, err := client.SendPacketEvents(ctx, from, to) + if err != nil { + m.logger.Warn("scanning send packet events", + "chainID", u.chainID, "from", from, "to", to, "err", err) + + return + } + + cursorTo := m.recordPackets(ctx, u, events, to) + + // Only advance when at least one new block was fully recorded; otherwise the + // same window is retried next tick (cursorTo < from means the earliest block + // failed to persist). + if cursorTo >= from { + if err := m.store.SetRelayCursor(ctx, u.cursorKey(), cursorTo); err != nil { + m.logger.Warn("advancing relay cursor", "cursorKey", u.cursorKey(), "to", cursorTo, "err", err) + } + } +} + +// scanFrom returns the first block to scan: cursor+1 when a cursor exists, +// otherwise finalized-lookback (saturating), inclusive, for a fresh start. +func (m *Monitor) scanFrom(ctx context.Context, u scanUnit, finalized uint64) (uint64, error) { + cursor, err := m.store.GetRelayCursor(ctx, u.cursorKey()) + switch { + case errors.Is(err, store.ErrNotFound): + return mathx.SaturatingSubU64(finalized, u.lookback), nil + case err != nil: + return 0, err + default: + return cursor + 1, nil + } +} + +// recordPackets inserts a PENDING row for every SendPacket event that belongs to +// the route and returns the height the cursor may safely advance to. Duplicate +// inserts are a no-op (CreatePacket upserts). If any insert fails, the cursor is +// held below the earliest failed event's block so that block is rescanned next +// tick and no packet is lost. +func (m *Monitor) recordPackets(ctx context.Context, u scanUnit, events []chains.PacketEvent, to uint64) uint64 { + firstFailedHeight, failed := uint64(0), false + + for _, ev := range events { + if ev.Kind != chains.KindSendPacket { + continue + } + if ev.Packet.SourceClient != u.sourceClientID { + continue + } + + input := store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: u.chainID, + DestinationChainID: u.destChainID, + SourceTxHash: ev.TxHash, + SourceTxTime: ev.BlockTime, + PacketSequenceNumber: ev.Packet.Sequence, + PacketSourceClientID: ev.Packet.SourceClient, + PacketDestinationClientID: ev.Packet.DestClient, + PacketTimeoutTimestamp: unixTime(ev.Packet.TimeoutTimestamp), + } + + if err := m.store.CreatePacket(ctx, input); err != nil { + m.logger.Warn("recording discovered packet", + "chainID", u.chainID, "sourceClientID", ev.Packet.SourceClient, + "sequence", ev.Packet.Sequence, "height", ev.Height, "err", err) + + if !failed || ev.Height < firstFailedHeight { + firstFailedHeight, failed = ev.Height, true + } + + continue + } + + m.logger.Debug("recorded discovered packet", + "chainID", u.chainID, "sourceClientID", ev.Packet.SourceClient, + "sequence", ev.Packet.Sequence) + } + + if failed { + // Advance only to the last fully-recorded block before the earliest + // failure so that block (and everything after) is retried next tick. + return mathx.SaturatingSubU64(firstFailedHeight, 1) + } + + return to +} + +func unixTime(seconds uint64) time.Time { + return time.Unix(int64(seconds), 0).UTC() //nolint:gosec // timeout timestamps fit in int64 +} diff --git a/link/internal/relay/autorelay/monitor_test.go b/link/internal/relay/autorelay/monitor_test.go new file mode 100644 index 000000000..a356c8e77 --- /dev/null +++ b/link/internal/relay/autorelay/monitor_test.go @@ -0,0 +1,492 @@ +package autorelay_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/relay/autorelay" + "github.com/cosmos/ibc/link/internal/store" +) + +const ( + chainA = "1" + chainB = "2" + clientA = "clientA" + clientB = "clientB" +) + +// ptr returns a pointer to v. +func ptr[T any](v T) *T { return &v } + +// twoWayConfig builds a bidirectional relayer config with one route on chainA +// (clientA -> clientB) unless extraRoutes adds more. +func routeConfig(routes ...config.RouteConfig) config.RelayerConfig { + return config.RelayerConfig{ + Clients: []config.ClientConfig{ + { + Alias: clientA, + ClientID: clientA, + ChainID: chainA, + CounterpartyChainID: chainB, + CounterpartyClientID: clientB, + // Every attestation client carries an attestor set (config-validated); + // the mirror's set supplies the source-chain finality offset (0 = latest). + AttestorSet: &config.AttestorSetConfig{Threshold: 1}, + }, + { + Alias: clientB, + ClientID: clientB, + ChainID: chainB, + CounterpartyChainID: chainA, + CounterpartyClientID: clientA, + AttestorSet: &config.AttestorSetConfig{Threshold: 1}, + }, + }, + Routes: routes, + } +} + +func sendEvent(seq uint64, sourceClient, destClient string) chains.PacketEvent { + return sendEventAt(seq, sourceClient, destClient, 100) +} + +func sendEventAt(seq uint64, sourceClient, destClient string, height uint64) chains.PacketEvent { + return chains.PacketEvent{ + Height: height, + BlockTime: time.Unix(1_700_000_000, 0).UTC(), + TxHash: "0xabc", + Kind: chains.KindSendPacket, + Packet: chains.Packet{ + Sequence: seq, + SourceClient: sourceClient, + DestClient: destClient, + TimeoutTimestamp: 1_800_000_000, + }, + } +} + +// cursorKey composes the per-route cursor key the monitor uses. +func cursorKey(chainID, clientID string) string { return chainID + "/" + clientID } + +// scanCall records the from/to of a SendPacketEvents call. +type scanCall struct{ from, to uint64 } + +type fakeChainClient struct { + mu sync.Mutex + latest uint64 + events []chains.PacketEvent + scans []scanCall +} + +func (f *fakeChainClient) LatestHeight(context.Context) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.latest, nil +} + +func (f *fakeChainClient) SendPacketEvents(_ context.Context, from, to uint64) ([]chains.PacketEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.scans = append(f.scans, scanCall{from: from, to: to}) + + return append([]chains.PacketEvent(nil), f.events...), nil +} + +func (f *fakeChainClient) scanCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.scans) +} + +func (f *fakeChainClient) lastScan() scanCall { + f.mu.Lock() + defer f.mu.Unlock() + + return f.scans[len(f.scans)-1] +} + +// unused chains.Client methods. +func (f *fakeChainClient) TxPacketEvents(context.Context, []byte) ([]chains.PacketEvent, error) { + return nil, nil +} +func (f *fakeChainClient) WriteAckEvents(context.Context, uint64, uint64) ([]chains.PacketEvent, error) { + return nil, nil +} +func (f *fakeChainClient) AckPacketEvents(context.Context, uint64, uint64) ([]chains.PacketEvent, error) { + return nil, nil +} +func (f *fakeChainClient) TimeoutPacketEvents(context.Context, uint64, uint64) ([]chains.PacketEvent, error) { + return nil, nil +} + +// FinalizedHeight mirrors LatestHeight lagged by the offset, matching the real +// evm client's offset semantics (offset 0 => latest). +func (f *fakeChainClient) FinalizedHeight(_ context.Context, offset *uint64) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if offset == nil || *offset == 0 { + return f.latest, nil + } + if *offset >= f.latest { + return 0, nil + } + + return f.latest - *offset, nil +} +func (f *fakeChainClient) HeaderTimestamp(context.Context, uint64) (time.Time, error) { + return time.Time{}, nil +} +func (f *fakeChainClient) GetCommitment(context.Context, [32]byte, uint64) ([32]byte, error) { + return [32]byte{}, nil +} + +type fakeClients struct { + clients map[string]chains.Client +} + +func (f *fakeClients) GetClient(chainID string) (chains.Client, error) { + c, ok := f.clients[chainID] + if !ok { + return nil, assert.AnError + } + + return c, nil +} + +func newStore(t *testing.T) store.Store { + t.Helper() + + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + _, err = db.MigrateUp() + require.NoError(t, err) + + return db +} + +func listPackets(t *testing.T, db store.Store) []store.Packet { + t.Helper() + + pkts, err := db.ListPackets(context.Background(), store.ListPacketsFilter{}) + require.NoError(t, err) + + return pkts +} + +func TestMonitorFreshStartUsesLookback(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 1000, events: []chains.PacketEvent{sendEvent(1, clientA, clientB)}} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA, AutoRelay: config.AutoRelayConfig{Lookback: 100}}), + }) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + // No cursor => fresh start at saturatingSub(latest, lookback) INCLUSIVE + // (ADR-011): 1000-100 = 900 .. 1000. + require.Equal(t, 1, client.scanCount()) + assert.Equal(t, scanCall{from: 900, to: 1000}, client.lastScan()) + + // Cursor advanced to latest, keyed per (chain, source client). + cursor, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(1000), cursor) + + pkts := listPackets(t, db) + require.Len(t, pkts, 1) + assert.Equal(t, store.RelayStatusPending, pkts[0].Status) + assert.Equal(t, chainB, pkts[0].DestinationChainID) + assert.Equal(t, uint64(1), pkts[0].PacketSequenceNumber) +} + +func TestMonitorCursorAdvances(t *testing.T) { + db := newStore(t) + require.NoError(t, db.SetRelayCursor(context.Background(), cursorKey(chainA, clientA), 500)) + + client := &fakeChainClient{latest: 600, events: []chains.PacketEvent{sendEvent(7, clientA, clientB)}} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA}), + }) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + require.Equal(t, 1, client.scanCount()) + assert.Equal(t, scanCall{from: 501, to: 600}, client.lastScan()) + + cursor, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(600), cursor) +} + +func TestMonitorWindowCap(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 10_000} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA, AutoRelay: config.AutoRelayConfig{Lookback: 9000}}), + MaxScan: 2000, + }) + require.NoError(t, err) + + // First tick: fresh start from 1000 (10000-9000, inclusive), capped at 2000 blocks. + mon.RunOnce(context.Background()) + assert.Equal(t, scanCall{from: 1000, to: 2999}, client.lastScan()) + cursor, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(2999), cursor) + + // Second tick: resumes from the persisted cursor, capped again. + mon.RunOnce(context.Background()) + assert.Equal(t, scanCall{from: 3000, to: 4999}, client.lastScan()) +} + +func TestMonitorDuplicateRediscoveryIsNoop(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 1000, events: []chains.PacketEvent{sendEvent(1, clientA, clientB)}} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA}), + }) + require.NoError(t, err) + + // Reset the cursor before the second pass so the same event is rediscovered. + mon.RunOnce(context.Background()) + require.NoError(t, db.SetRelayCursor(context.Background(), cursorKey(chainA, clientA), 0)) + mon.RunOnce(context.Background()) + + pkts := listPackets(t, db) + assert.Len(t, pkts, 1, "duplicate insert must be a no-op") +} + +func TestMonitorDisabledRouteNotScanned(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 1000, events: []chains.PacketEvent{sendEvent(1, clientA, clientB)}} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{ + SourceClient: clientA, + AutoRelay: config.AutoRelayConfig{Enabled: ptr(false)}, + }), + }) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + assert.Equal(t, 0, client.scanCount(), "disabled route must not be scanned") + assert.Empty(t, listPackets(t, db)) +} + +func TestMonitorMultipleRoutesOneChainPerRouteCursors(t *testing.T) { + db := newStore(t) + + // Two source clients on chainA. Each route owns its own cursor and scan so a + // later-enabled route does not inherit another route's progress. + cfg := config.RelayerConfig{ + Clients: []config.ClientConfig{ + {Alias: clientA, ClientID: clientA, ChainID: chainA, CounterpartyChainID: chainB, CounterpartyClientID: clientB}, + { + Alias: clientB, ClientID: clientB, ChainID: chainB, CounterpartyChainID: chainA, CounterpartyClientID: clientA, + AttestorSet: &config.AttestorSetConfig{Threshold: 1}, + }, + {Alias: "clientC", ClientID: "clientC", ChainID: chainA, CounterpartyChainID: chainB, CounterpartyClientID: "clientD"}, + { + Alias: "clientD", ClientID: "clientD", ChainID: chainB, CounterpartyChainID: chainA, CounterpartyClientID: "clientC", + AttestorSet: &config.AttestorSetConfig{Threshold: 1}, + }, + }, + Routes: []config.RouteConfig{ + {SourceClient: clientA}, + {SourceClient: "clientC"}, + }, + } + + client := &fakeChainClient{ + latest: 1000, + events: []chains.PacketEvent{ + sendEvent(1, clientA, clientB), + sendEvent(2, "clientC", "clientD"), + sendEvent(3, "unconfigured", "x"), + }, + } + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{Store: db, Clients: clients, Relayer: cfg}) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + // One scan per route (both on chainA), and one cursor per route. + assert.Equal(t, 2, client.scanCount(), "one scan per route per tick") + + curA, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(1000), curA) + curC, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, "clientC")) + require.NoError(t, err) + assert.Equal(t, uint64(1000), curC) + + pkts := listPackets(t, db) + require.Len(t, pkts, 2, "each route records only its own client's packet; unconfigured skipped") + + seqs := map[uint64]bool{} + for _, p := range pkts { + seqs[p.PacketSequenceNumber] = true + } + assert.True(t, seqs[1] && seqs[2]) + assert.False(t, seqs[3]) +} + +// failingStore wraps a Store and fails CreatePacket for a chosen sequence, +// exercising the cursor-hold-on-failed-insert path (F4). +type failingStore struct { + store.Store + failSeq uint64 +} + +func (f *failingStore) CreatePacket(ctx context.Context, input store.CreatePacket) error { + if input.PacketSequenceNumber == f.failSeq { + return assert.AnError + } + + return f.Store.CreatePacket(ctx, input) +} + +// TestMonitorFailedInsertHoldsCursor proves a failed insert does not advance the +// cursor past its block: the block is rescanned next tick and the packet is not +// lost. +func TestMonitorFailedInsertHoldsCursor(t *testing.T) { + db := newStore(t) + fs := &failingStore{Store: db, failSeq: 5} + + // Two events: seq 4 at height 950 (ok), seq 5 at height 970 (fails). + client := &fakeChainClient{ + latest: 1000, + events: []chains.PacketEvent{ + sendEventAt(4, clientA, clientB, 950), + sendEventAt(5, clientA, clientB, 970), + }, + } + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: fs, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA, AutoRelay: config.AutoRelayConfig{Lookback: 100}}), + }) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + // Cursor held at 969 (one below the earliest failed block), NOT advanced to 1000. + cursor, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(969), cursor) + + // Only seq 4 persisted; seq 5 was lost to the failure and must be retried. + pkts := listPackets(t, db) + require.Len(t, pkts, 1) + assert.Equal(t, uint64(4), pkts[0].PacketSequenceNumber) + + // Next tick with a healthy store retries from 970 and records seq 5. + mon2, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA, AutoRelay: config.AutoRelayConfig{Lookback: 100}}), + }) + require.NoError(t, err) + mon2.RunOnce(context.Background()) + + assert.Equal(t, scanCall{from: 970, to: 1000}, client.lastScan()) + assert.Len(t, listPackets(t, db), 2, "retry recovers the previously failed packet") +} + +// TestMonitorScansToFinalizedHeight proves the monitor scans only up to the +// source chain's finalized height (latest - offset), not latest, so a reorg +// below the finalized head cannot strand a packet the cursor crossed. +func TestMonitorScansToFinalizedHeight(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 1000} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + // The recv-side (mirror) client's attestor set carries the source-chain + // finality offset of 20. + cfg := config.RelayerConfig{ + Clients: []config.ClientConfig{ + {Alias: clientA, ClientID: clientA, ChainID: chainA, CounterpartyChainID: chainB, CounterpartyClientID: clientB}, + { + Alias: clientB, ClientID: clientB, ChainID: chainB, CounterpartyChainID: chainA, CounterpartyClientID: clientA, + AttestorSet: &config.AttestorSetConfig{CounterpartyChainFinalityOffset: 20, Threshold: 1}, + }, + }, + Routes: []config.RouteConfig{{SourceClient: clientA, AutoRelay: config.AutoRelayConfig{Lookback: 100}}}, + } + + mon, err := autorelay.New(autorelay.Params{Store: db, Clients: clients, Relayer: cfg}) + require.NoError(t, err) + + mon.RunOnce(context.Background()) + + // Finalized = 1000 - 20 = 980; fresh start = 980-100 = 880 .. 980. + assert.Equal(t, scanCall{from: 880, to: 980}, client.lastScan()) + cursor, err := db.GetRelayCursor(context.Background(), cursorKey(chainA, clientA)) + require.NoError(t, err) + assert.Equal(t, uint64(980), cursor) +} + +func TestMonitorRunStopsOnContextCancel(t *testing.T) { + db := newStore(t) + client := &fakeChainClient{latest: 1000} + clients := &fakeClients{clients: map[string]chains.Client{chainA: client}} + + mon, err := autorelay.New(autorelay.Params{ + Store: db, + Clients: clients, + Relayer: routeConfig(config.RouteConfig{SourceClient: clientA}), + Tick: time.Millisecond, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- mon.Run(ctx) }() + + time.Sleep(10 * time.Millisecond) + cancel() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("Run did not return after cancel") + } +} diff --git a/link/internal/relay/engine/deliver.go b/link/internal/relay/engine/deliver.go new file mode 100644 index 000000000..615a54245 --- /dev/null +++ b/link/internal/relay/engine/deliver.go @@ -0,0 +1,268 @@ +package engine + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/store" +) + +// scanPageSize is the block span of one page of a backward event scan. +const scanPageSize uint64 = 10_000 + +// scanPagesPerTick caps how many pages a single tick scans for one packet so an +// old out-of-band event is found across ticks without stalling the loop. +const scanPagesPerTick = 5 + +// externalRelayer marks recv/ack rows back-filled from packets relayed outside +// this engine, where the true relayer address is unknown. +const externalRelayer = "external" + +// deliverSpec parameterises a batched delivery for one relay kind. +type deliverSpec struct { + relayKind chains.RelayKind + proofKind chains.ProofKind + + proofGen ProofGenerator + submitter Submitter + txBuilder TxBuilder + + // updateClientID is the client updated on the tx target chain (cd for recv, + // cs for ack/timeout). + updateClientID string + targetChainID string + txType store.TxType + batchSize int + + // eligible filters a packet immediately before submission (e.g. recv skips + // packets already received). A nil filter admits everything. + eligible func(ctx context.Context, pc *packetContext) bool + // acksFor returns the acknowledgement bytes for a packet (ack deliveries). + acksFor func(pc *packetContext) [][]byte + // persist records the delivery tx hash against a packet row, using the given + // transaction-bound repository so it commits atomically with the tx-submission + // row and packet links. + persist func(ctx context.Context, repo store.Repository, key store.PacketKey, hash, relayer string) error +} + +func (e *Engine) deliverRecv(ctx context.Context, r *route, batch []*packetContext) { + e.deliver(ctx, r, batch, deliverSpec{ + relayKind: chains.RelayRecv, + proofKind: chains.KindPacketCommitment, + proofGen: r.recvProofGen, + submitter: r.dstSubmitter, + txBuilder: r.dstTxBuilder, + updateClientID: r.dstClientID, + targetChainID: r.dstChainID, + txType: store.TxTypeRecvPacket, + batchSize: r.recvBatch, + eligible: func(ctx context.Context, pc *packetContext) bool { + received, err := e.receiptPresent(ctx, r, pc.db.PacketSequenceNumber) + if err != nil { + e.logger.Warn("re-checking receipt before recv", "route", r.key, "err", err) + + return false + } + + return !received + }, + persist: e.persistRecv, + }) +} + +func (e *Engine) deliverAck(ctx context.Context, r *route, batch []*packetContext) { + e.deliver(ctx, r, batch, deliverSpec{ + relayKind: chains.RelayAck, + proofKind: chains.KindAcknowledgement, + proofGen: r.ackProofGen, + submitter: r.srcSubmitter, + txBuilder: r.srcTxBuilder, + updateClientID: r.srcClientID, + targetChainID: r.srcChainID, + txType: store.TxTypeAckPacket, + batchSize: r.srcBatch, + acksFor: func(pc *packetContext) [][]byte { return pc.ackBytes }, + persist: e.persistAck, + }) +} + +func (e *Engine) deliverTimeout(ctx context.Context, r *route, batch []*packetContext) { + e.deliver(ctx, r, batch, deliverSpec{ + relayKind: chains.RelayTimeout, + proofKind: chains.KindReceiptAbsence, + proofGen: r.ackProofGen, + submitter: r.srcSubmitter, + txBuilder: r.srcTxBuilder, + updateClientID: r.srcClientID, + targetChainID: r.srcChainID, + txType: store.TxTypeTimeoutPacket, + batchSize: r.srcBatch, + persist: e.persistTimeout, + }) +} + +// deliver chunks the batch by spec.batchSize and submits one client-update + +// packet multicall per chunk, pinning every item to a single attested height and +// a single state proof (the light client freezes on conflicting heights). +func (e *Engine) deliver(ctx context.Context, r *route, batch []*packetContext, spec deliverSpec) { + for start := 0; start < len(batch); start += spec.batchSize { + end := start + spec.batchSize + if end > len(batch) { + end = len(batch) + } + + e.deliverChunk(ctx, r, batch[start:end], spec) + } +} + +func (e *Engine) deliverChunk(ctx context.Context, r *route, chunk []*packetContext, spec deliverSpec) { + eligible := make([]*packetContext, 0, len(chunk)) + for _, pc := range chunk { + if spec.eligible != nil && !spec.eligible(ctx, pc) { + continue + } + eligible = append(eligible, pc) + } + if len(eligible) == 0 { + return + } + + packets := make([]chains.Packet, len(eligible)) + for i, pc := range eligible { + packets[i] = pc.packet + } + + height, err := spec.proofGen.LatestHeight(ctx) + if err != nil { + e.logger.Warn("proof-gen latest height", "route", r.key, "kind", spec.relayKind, "err", err) + + return + } + + proofs, proofHeight, err := spec.proofGen.PacketProofs(ctx, height, spec.proofKind, packets) + if err != nil { + e.logger.Warn("building packet proofs", "route", r.key, "kind", spec.relayKind, "err", err) + + return + } + + stateProof, stateHeight, err := spec.proofGen.StateProof(ctx, height) + if err != nil { + e.logger.Warn("building state proof", "route", r.key, "kind", spec.relayKind, "err", err) + + return + } + + if proofHeight != stateHeight { + e.logger.Error("packet and state proof heights differ; skipping batch to avoid client freeze", + "route", r.key, "packetHeight", proofHeight, "stateHeight", stateHeight) + + return + } + + items := make([]chains.PacketRelayItem, len(eligible)) + for i, pc := range eligible { + item := chains.PacketRelayItem{ + Kind: spec.relayKind, + Packet: pc.packet, + Proof: proofs[i], + ProofHeight: proofHeight, + } + if spec.acksFor != nil { + item.Acks = spec.acksFor(pc) + } + items[i] = item + } + + update := &chains.ClientUpdate{ClientID: spec.updateClientID, StateProof: stateProof} + + to, data, err := spec.txBuilder.BuildRelayTx(update, items) + if err != nil { + e.logger.Warn("building relay tx", "route", r.key, "kind", spec.relayKind, "err", err) + + return + } + + hash, err := spec.submitter.Submit(ctx, to, data) + if err != nil { + e.logger.Warn("submitting relay tx", "route", r.key, "kind", spec.relayKind, "err", err) + + return + } + + e.recordSubmission(ctx, eligible, spec, hash) +} + +// recordSubmission commits every post-broadcast DB write for the chunk in a +// SINGLE transaction: each packet's delivery tx hash, the tx-submission audit +// row, and the packet links. All-or-nothing avoids a torn record (a packet +// pointing at a tx with no audit row, or an audit row with missing links). +// +// The tx is already broadcast when this runs, so a transaction failure only logs +// an Error: the next tick re-submits (a recv is a router no-op; an ack/timeout +// duplicate wastes gas or reverts). The tick loop is the retry, so there is no +// inline retry here. +func (e *Engine) recordSubmission(ctx context.Context, chunk []*packetContext, spec deliverSpec, hash common.Hash) { + relayer := spec.submitter.From().Hex() + hashHex := hash.Hex() + + if err := e.store.ExecTx(ctx, func(repo store.Repository) error { + for _, pc := range chunk { + if err := spec.persist(ctx, repo, pc.key(), hashHex, relayer); err != nil { + return errors.Wrapf(err, "recording delivery tx for packet %d", pc.db.PacketSequenceNumber) + } + } + + submissionID, err := repo.CreateTxSubmission(ctx, store.CreateTxSubmission{ + TxHash: hashHex, + ChainID: spec.targetChainID, + TxType: spec.txType, + RelayerAddress: relayer, + SubmittedAt: e.now(), + }) + if err != nil { + return errors.Wrap(err, "creating tx submission") + } + + for _, pc := range chunk { + if err := repo.LinkPacketTxSubmission(ctx, pc.db.ID, submissionID); err != nil { + return errors.Wrapf(err, "linking tx submission for packet %d", pc.db.ID) + } + } + + return nil + }); err != nil { + e.logger.Error("recording post-broadcast state failed; tx already broadcast, next tick re-submits", + "kind", spec.relayKind, "hash", hashHex, "err", err) + } +} + +func (e *Engine) persistRecv( + ctx context.Context, repo store.Repository, key store.PacketKey, hash, relayer string, +) error { + return repo.SetPacketRecvTx(ctx, key, hash, e.now(), relayer) +} + +func (e *Engine) persistAck( + ctx context.Context, repo store.Repository, key store.PacketKey, hash, relayer string, +) error { + return repo.SetPacketAckTx(ctx, key, hash, e.now(), relayer) +} + +func (e *Engine) persistTimeout( + ctx context.Context, repo store.Repository, key store.PacketKey, hash, relayer string, +) error { + return repo.SetPacketTimeoutTx(ctx, key, hash, e.now(), relayer) +} + +func hexToHash(h string) (common.Hash, error) { + var hash common.Hash + if err := hash.UnmarshalText([]byte(h)); err != nil { + return common.Hash{}, err + } + + return hash, nil +} diff --git a/link/internal/relay/engine/engine.go b/link/internal/relay/engine/engine.go new file mode 100644 index 000000000..cc011c999 --- /dev/null +++ b/link/internal/relay/engine/engine.go @@ -0,0 +1,396 @@ +// Package engine implements the IBC v2 packet relay state machine. It consumes +// packets recorded by the relayer service, drives each through its recv → ack or +// timeout lifecycle across the source and destination chains, and persists every +// transition so a restart resumes from the database. +package engine + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/clientkey" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/store" +) + +// defaultTick is the dispatcher poll interval when none is configured. +const defaultTick = time.Second + +// defaultPacketBatchSize caps the number of packet relay items per submitted tx. +const defaultPacketBatchSize = 50 + +// retryExpiry is how long a submitted relay tx may sit without a receipt before +// it is considered stuck and re-submitted (matches the reference relayer). +const retryExpiry = 2 * time.Minute + +// Store is the subset of store.Repository the engine reads and writes. The +// concrete store.Store satisfies it. +type Store interface { + ListUnfinishedPackets(ctx context.Context) ([]store.Packet, error) + UpdatePacketStatus(ctx context.Context, key store.PacketKey, status store.RelayStatus) error + + SetPacketRecvTx(ctx context.Context, key store.PacketKey, txHash string, txTime time.Time, relayer string) error + SetPacketAckTx(ctx context.Context, key store.PacketKey, txHash string, txTime time.Time, relayer string) error + SetPacketTimeoutTx(ctx context.Context, key store.PacketKey, txHash string, txTime time.Time, relayer string) error + SetPacketWriteAckTx( + ctx context.Context, + key store.PacketKey, + txHash string, + txTime time.Time, + status store.WriteAckStatus, + ) error + SetPacketWriteAckStatus(ctx context.Context, key store.PacketKey, status store.WriteAckStatus) error + + ClearPacketRecvTx(ctx context.Context, key store.PacketKey) error + ClearPacketAckTx(ctx context.Context, key store.PacketKey) error + ClearPacketTimeoutTx(ctx context.Context, key store.PacketKey) error + + SetPacketSourceTxFinalizedTime(ctx context.Context, key store.PacketKey, t time.Time) error + SetPacketWriteAckTxFinalizedTime(ctx context.Context, key store.PacketKey, t time.Time) error + + CreateTxSubmission(ctx context.Context, input store.CreateTxSubmission) (int64, error) + LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error + + // ExecTx runs fn in a single transaction; fn's Repository is bound to it and + // rolled back on error. Used to commit a submission row and its packet links + // atomically. + ExecTx(ctx context.Context, fn func(store.Repository) error) error + + // ResolveTxSubmission records the terminal outcome of a broadcast relay tx + // keyed by (chainID, txHash). It returns the number of rows actually + // transitioned (0 when already resolved), so the engine counts each tx once. + ResolveTxSubmission(ctx context.Context, input store.ResolveTxSubmission) (int64, error) +} + +// ChainClients resolves a chains.Client by chain id. +type ChainClients interface { + GetClient(chainID string) (chains.Client, error) +} + +// Submitter delivers relay transactions to a single chain. The evm.Submitter +// satisfies it. +type Submitter interface { + Submit(ctx context.Context, to common.Address, data []byte) (common.Hash, error) + // Receipt fetches a transaction's receipt, returning (nil, nil) when the tx is + // not yet included in a block. It is the single source of truth for both + // inclusion and execution outcome (receipt.Status), so an in-flight tx is + // resolved from one read without racing separate inclusion/success/gas reads. + Receipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + // Expired reports whether a not-yet-included tx has sat past its retry expiry + // (latest block time is after sentAt+expiry). It performs no receipt read; the + // caller establishes non-inclusion via Receipt. + Expired(ctx context.Context, sentAt time.Time, expiry time.Duration) (bool, error) + From() common.Address +} + +// TxBuilder assembles a client-update + packet-relay multicall for a single +// chain's router. The evm.TxBuilder satisfies it. +type TxBuilder interface { + BuildRelayTx(update *chains.ClientUpdate, items []chains.PacketRelayItem) (common.Address, []byte, error) +} + +// ProofGenerator produces packet and state proofs for a single light client. +// The proofgen.Generator satisfies it. +type ProofGenerator interface { + PacketProofs( + ctx context.Context, + height uint64, + kind chains.ProofKind, + packets []chains.Packet, + ) ([][]byte, uint64, error) + StateProof(ctx context.Context, height uint64) ([]byte, uint64, error) + LatestHeight(ctx context.Context) (uint64, error) +} + +// Params configures an Engine. +type Params struct { + Store Store + ChainClients ChainClients + // Submitters is keyed by chain id. + Submitters map[string]Submitter + // TxBuilders is keyed by chain id. + TxBuilders map[string]TxBuilder + // ProofGens is keyed by "chainID/clientID". + ProofGens map[string]ProofGenerator + Relayer config.RelayerConfig + Logger *slog.Logger + + // Metrics records relay activity; a nil *Metrics is a valid no-op recorder. + Metrics *metrics.Metrics + + // Tick is the dispatcher poll interval; defaults to 1s. + Tick time.Duration + // Now injects the clock; defaults to time.Now. + Now func() time.Time +} + +// route is the fully-resolved processing context for one configured source +// client (S, cs) relaying to its counterparty (D, cd). +type route struct { + key string + + srcChainID string // S: chain packets are sent from + srcClientID string // cs: client on S tracking D + dstChainID string // D: chain packets are received on + dstClientID string // cd: client on D tracking S + + srcClient chains.Client // reads S (source commitment, source tx events) + dstClient chains.Client // reads D (receipts, write acks, header times) + + // recv is submitted on D and verified by cd; its proofs come from the + // (D, cd) attestor set, which attests S. + dstSubmitter Submitter + dstTxBuilder TxBuilder + recvProofGen ProofGenerator + recvBatch int + + // ack and timeout are submitted on S and verified by cs; their proofs come + // from the (S, cs) attestor set, which attests D. + srcSubmitter Submitter + srcTxBuilder TxBuilder + ackProofGen ProofGenerator + srcBatch int +} + +// Engine is the packet relay state machine. +type Engine struct { + store Store + logger *slog.Logger + metrics *metrics.Metrics + tick time.Duration + now func() time.Time + + routes map[string]*route + + // scanState resumes paged backward event scans across ticks, keyed per + // (packet, scan purpose). Guarded by scanMu. + scanMu sync.Mutex + scanState map[string]*pagedScanState +} + +// New builds an Engine, resolving one route per configured client entry that has +// a mirror entry. It errors on missing mirrors or missing chain/submitter/proof +// dependencies. +func New(p Params) (*Engine, error) { + switch { + case p.Store == nil: + return nil, errors.New("store is required") + case p.ChainClients == nil: + return nil, errors.New("chain clients are required") + } + + logger := p.Logger + if logger == nil { + logger = slog.Default() + } + logger = logger.With("component", "relay/engine") + + tick := p.Tick + if tick <= 0 { + tick = defaultTick + } + + now := p.Now + if now == nil { + now = time.Now + } + + routes, err := buildRoutes(p) + if err != nil { + return nil, err + } + + return &Engine{ + store: p.Store, + logger: logger, + metrics: p.Metrics, + tick: tick, + now: now, + routes: routes, + scanState: make(map[string]*pagedScanState), + }, nil +} + +// buildRoutes builds one processing route per client referenced as a source by +// relayer.routesToRelay. Routes are built for both auto and manual relaying; a +// configured client that is not a route source gets no engine route (and the +// service rejects /relay requests for it). Mirror entries need not be routes — +// they only supply the counterparty proofs. +func buildRoutes(p Params) (map[string]*route, error) { + routes := make(map[string]*route, len(p.Relayer.Routes)) + + for _, rc := range p.Relayer.Routes { + client, ok := p.Relayer.ClientByAlias(rc.SourceClient) + if !ok { + return nil, errors.Errorf("route references unknown client %q", rc.SourceClient) + } + + mirror, ok := p.Relayer.Client(client.CounterpartyChainID, client.CounterpartyClientID) + if !ok { + return nil, errors.Errorf( + "client %q (%s/%s) is missing its mirror entry (%s/%s)", + client.Alias, client.ChainID, client.ClientID, + client.CounterpartyChainID, client.CounterpartyClientID, + ) + } + + r, err := resolveRoute(p, client, mirror) + if err != nil { + return nil, errors.Wrapf(err, "resolving route for client %q", client.Alias) + } + + routes[r.key] = r + } + + return routes, nil +} + +func resolveRoute(p Params, client, mirror config.ClientConfig) (*route, error) { + src := client.ChainID + dst := client.CounterpartyChainID + + srcClient, err := p.ChainClients.GetClient(src) + if err != nil { + return nil, errors.Wrapf(err, "source chain %q", src) + } + + dstClient, err := p.ChainClients.GetClient(dst) + if err != nil { + return nil, errors.Wrapf(err, "destination chain %q", dst) + } + + srcSubmitter, ok := p.Submitters[src] + if !ok { + return nil, errors.Errorf("no submitter for source chain %q", src) + } + + dstSubmitter, ok := p.Submitters[dst] + if !ok { + return nil, errors.Errorf("no submitter for destination chain %q", dst) + } + + srcBuilder, ok := p.TxBuilders[src] + if !ok { + return nil, errors.Errorf("no tx builder for source chain %q", src) + } + + dstBuilder, ok := p.TxBuilders[dst] + if !ok { + return nil, errors.Errorf("no tx builder for destination chain %q", dst) + } + + // recv proofs attest S and are verified by cd (the mirror entry, on D). + recvProofGen, ok := p.ProofGens[ClientKey(mirror.ChainID, mirror.ClientID)] + if !ok { + return nil, errors.Errorf("no proof generator for recv client %s/%s", mirror.ChainID, mirror.ClientID) + } + + // ack/timeout proofs attest D and are verified by cs (this entry, on S). + ackProofGen, ok := p.ProofGens[ClientKey(client.ChainID, client.ClientID)] + if !ok { + return nil, errors.Errorf("no proof generator for ack client %s/%s", client.ChainID, client.ClientID) + } + + return &route{ + key: ClientKey(src, client.ClientID), + srcChainID: src, + srcClientID: client.ClientID, + dstChainID: dst, + dstClientID: client.CounterpartyClientID, + srcClient: srcClient, + dstClient: dstClient, + dstSubmitter: dstSubmitter, + dstTxBuilder: dstBuilder, + recvProofGen: recvProofGen, + recvBatch: p.Relayer.PacketBatchSizeFor(dst, defaultPacketBatchSize), + srcSubmitter: srcSubmitter, + srcTxBuilder: srcBuilder, + ackProofGen: ackProofGen, + srcBatch: p.Relayer.PacketBatchSizeFor(src, defaultPacketBatchSize), + }, nil +} + +// Run drives the dispatcher loop until ctx is canceled. The first tick fires +// immediately; thereafter every e.tick. It returns nil once ctx is done and the +// in-flight tick has finished. +func (e *Engine) Run(ctx context.Context) error { + ticker := time.NewTicker(e.tick) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + e.RunOnce(ctx) + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +// RunOnce executes a single dispatcher tick: it lists unfinished packets, groups +// them by route, and processes each route (routes concurrently, packets within a +// route sequentially). +func (e *Engine) RunOnce(ctx context.Context) { + packets, err := e.store.ListUnfinishedPackets(ctx) + if err != nil { + if ctx.Err() == nil { + e.logger.Warn("listing unfinished packets", "err", err) + } + + return + } + + grouped := make(map[string][]store.Packet) + for _, p := range packets { + key := ClientKey(p.SourceChainID, p.PacketSourceClientID) + if _, ok := e.routes[key]; !ok { + e.logger.Warn("skipping packet for unknown route", + "sourceChainID", p.SourceChainID, "sourceClientID", p.PacketSourceClientID, + "sequence", p.PacketSequenceNumber) + + continue + } + grouped[key] = append(grouped[key], p) + } + + done := make(chan struct{}, len(grouped)) + for key, pkts := range grouped { + go func() { + defer func() { + if r := recover(); r != nil { + e.logger.Error("route processing panicked", "route", key, "panic", r) + } + done <- struct{}{} + }() + + e.processRoute(ctx, e.routes[key], pkts) + }() + } + + for range grouped { + <-done + } +} + +// ClientKey is the map-key protocol for routes and proof generators: +// "/". Bootstrap keys the ProofGens map with the same helper. +// It delegates to the canonical clientkey encoding shared with the relay cursor +// and wire route-id formats. +func ClientKey(chainID, clientID string) string { + return clientkey.Format(chainID, clientID) +} diff --git a/link/internal/relay/engine/engine_test.go b/link/internal/relay/engine/engine_test.go new file mode 100644 index 000000000..bf09ae5fa --- /dev/null +++ b/link/internal/relay/engine/engine_test.go @@ -0,0 +1,987 @@ +package engine_test + +import ( + "context" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/relay/engine" + "github.com/cosmos/ibc/link/internal/store" +) + +const ( + chainS = "1" + chainD = "2" + clientS = "clientS" + clientD = "clientD" + + sendHeight uint64 = 100 + writeAckHeight uint64 = 300 + attestHeight uint64 = 500 +) + +var ( + nonzeroCommitment = [32]byte{0x01} + fixedNow = time.Unix(2_000_000, 0).UTC() + + // universalErrorAck mirrors the engine constant (sha256 of the marker). + universalErrorAckBytes = common.FromHex( + "0x4774d4a575993f963b1c06573736617a457abef8589178db8d10c94b4ab511ab", + ) + successAckBytes = []byte("success-ack") +) + +type harness struct { + t *testing.T + ctx context.Context + db store.Store + store *recordingStore + srcClient *fakeChainClient + dstClient *fakeChainClient + subSrc *fakeSubmitter + subDst *fakeSubmitter + builderSrc *fakeTxBuilder + builderDst *fakeTxBuilder + recvPG *fakeProofGen + ackPG *fakeProofGen + metrics *metrics.Metrics + engine *engine.Engine +} + +func newHarness(t *testing.T, overrides ...config.RelayerChainOverride) *harness { + t.Helper() + ctx := context.Background() + + sqlite, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlite.Close() }) + _, err = sqlite.MigrateUp() + require.NoError(t, err) + + h := &harness{ + t: t, + ctx: ctx, + db: sqlite, + store: newRecordingStore(sqlite), + srcClient: newFakeChainClient(), + dstClient: newFakeChainClient(), + subSrc: newFakeSubmitter(1000), + subDst: newFakeSubmitter(2000), + builderSrc: newFakeTxBuilder(), + builderDst: newFakeTxBuilder(), + recvPG: newFakeProofGen(attestHeight), + ackPG: newFakeProofGen(attestHeight), + metrics: metrics.New(), + } + + // Block times track the (fixed) wall clock. + h.srcClient.defaultHeaderTime = fixedNow + h.dstClient.defaultHeaderTime = fixedNow + + // Both relay txs land successfully by default. + h.subSrc.defaultIncluded, h.subSrc.defaultSucceeded = true, true + h.subDst.defaultIncluded, h.subDst.defaultSucceeded = true, true + + relayerCfg := config.RelayerConfig{ + Signer: "relayer", + ChainOverrides: overrides, + Clients: []config.ClientConfig{ + { + Alias: "s2d", ClientID: clientS, ChainID: chainS, + CounterpartyChainID: chainD, CounterpartyClientID: clientD, + Type: config.ClientTypeAttestation, + }, + { + Alias: "d2s", ClientID: clientD, ChainID: chainD, + CounterpartyChainID: chainS, CounterpartyClientID: clientS, + Type: config.ClientTypeAttestation, + }, + }, + // Both directions are relayed routes; the engine builds a route per + // routesToRelay source client. + Routes: []config.RouteConfig{ + {SourceClient: "s2d"}, + {SourceClient: "d2s"}, + }, + } + + eng, err := engine.New(engine.Params{ + Store: h.store, + ChainClients: &fakeChainClients{clients: map[string]chains.Client{chainS: h.srcClient, chainD: h.dstClient}}, + Submitters: map[string]engine.Submitter{chainS: h.subSrc, chainD: h.subDst}, + TxBuilders: map[string]engine.TxBuilder{chainS: h.builderSrc, chainD: h.builderDst}, + ProofGens: map[string]engine.ProofGenerator{ + proofGenKey(chainD, clientD): h.recvPG, + proofGenKey(chainS, clientS): h.ackPG, + }, + Relayer: relayerCfg, + Metrics: h.metrics, + Now: func() time.Time { return fixedNow }, + }) + require.NoError(t, err) + h.engine = eng + + return h +} + +func proofGenKey(chainID, clientID string) string { return chainID + "/" + clientID } + +func txHashHex(seq uint64) string { + return common.BigToHash(new(big.Int).SetUint64(seq + 0xf00)).Hex() +} + +// seedPacket records a PENDING packet plus its source SendPacket event and the +// live source commitment. +func (h *harness) seedPacket(seq uint64, timeout time.Time) string { + h.t.Helper() + srcTx := txHashHex(seq) + + h.srcClient.setTxEvents(srcTx, []chains.PacketEvent{{ + Kind: chains.KindSendPacket, + Height: sendHeight, + BlockTime: fixedNow, + TxHash: srcTx, + Packet: h.packet(seq, timeout), + }}) + h.srcClient.setCommitment(srcCommitmentPath(seq), nonzeroCommitment) + + require.NoError(h.t, h.db.CreatePacket(h.ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainS, + DestinationChainID: chainD, + SourceTxHash: srcTx, + SourceTxTime: fixedNow, + PacketSequenceNumber: seq, + PacketSourceClientID: clientS, + PacketDestinationClientID: clientD, + PacketTimeoutTimestamp: timeout, + })) + + return srcTx +} + +func (h *harness) packet(seq uint64, timeout time.Time) chains.Packet { + return chains.Packet{ + Sequence: seq, + SourceClient: clientS, + DestClient: clientD, + TimeoutTimestamp: uint64(timeout.Unix()), //nolint:gosec + Payloads: []chains.Payload{{SourcePort: "transfer", DestPort: "transfer", Version: "v1", Encoding: "abi"}}, + } +} + +// seedWriteAck attaches a WriteAcknowledgement event to a recv tx (and the dest +// range scan) so the ack flow can proceed. +func (h *harness) seedWriteAck(seq uint64, recvTx string, ack []byte) { + ev := chains.PacketEvent{ + Kind: chains.KindWriteAck, + Height: writeAckHeight, + BlockTime: fixedNow, + TxHash: recvTx, + Packet: h.packet(seq, fixedNow.Add(time.Hour)), + Acks: [][]byte{ack}, + } + h.dstClient.setTxEvents(recvTx, []chains.PacketEvent{ev}) + + h.dstClient.mu.Lock() + h.dstClient.writeAckScan = append(h.dstClient.writeAckScan, ev) + h.dstClient.mu.Unlock() +} + +func (h *harness) getPacket(seq uint64) *store.Packet { + h.t.Helper() + p, err := h.db.GetPacket(h.ctx, store.PacketKey{ + SourceChainID: chainS, + PacketSequenceNumber: seq, + PacketSourceClientID: clientS, + }) + require.NoError(h.t, err) + + return p +} + +// txSubmission fetches the tx-submission row recorded for (chainID, txHash). +func (h *harness) txSubmission(chainID, txHash string) *store.TxSubmission { + h.t.Helper() + sub, err := h.db.GetTxSubmission(h.ctx, chainID, txHash) + require.NoError(h.t, err) + + return sub +} + +// defaultGasCost is the wei gas cost of the fake submitter's default receipt +// (21000 gas * 1 gwei). +const defaultGasCost = "21000000000000" + +func srcCommitmentPath(seq uint64) [32]byte { + return attestation.PathHash(attestation.PacketCommitmentPath(clientS, seq)) +} + +func receiptPath(seq uint64) [32]byte { + return attestation.PathHash(attestation.PacketReceiptPath(clientD, seq)) +} + +// --- Tests ----------------------------------------------------------------- + +func TestHappyPathRecvAckComplete(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Tick 1: deliver recv on the destination chain. + h.engine.RunOnce(h.ctx) + require.Equal(t, 1, h.subDst.submitCount(), "one recv submission") + require.Equal(t, 0, h.subSrc.submitCount(), "no source submission yet") + recvHash := h.subDst.lastHash() + p := h.getPacket(seq) + require.NotNil(t, p.RecvTxHash) + assert.Equal(t, recvHash.Hex(), *p.RecvTxHash) + assert.Equal(t, store.RelayStatusDeliverRecvPacket, p.Status) + + // The recv landed and emitted a success write ack. + h.seedWriteAck(seq, recvHash.Hex(), successAckBytes) + + // Tick 2: recv confirmed (resolves SUCCESS), write ack recorded, deliver ack. + h.engine.RunOnce(h.ctx) + require.Equal(t, 1, h.subSrc.submitCount(), "one ack submission") + ackHash := h.subSrc.lastHash() + p = h.getPacket(seq) + require.NotNil(t, p.WriteAckStatus) + assert.Equal(t, store.WriteAckStatusSuccess, *p.WriteAckStatus) + require.NotNil(t, p.AckTxHash) + assert.Equal(t, store.RelayStatusDeliverAckPacket, p.Status) + assert.NotNil(t, p.WriteAckTxFinalizedTime) + assert.NotNil(t, p.SourceTxFinalizedTime) + + // The confirmed recv left its submission resolved SUCCESS with the receipt gas. + recvSub := h.txSubmission(chainD, recvHash.Hex()) + assert.Equal(t, store.TxSubmissionStatusSuccess, recvSub.Status) + require.NotNil(t, recvSub.GasCostAmount) + assert.Equal(t, defaultGasCost, *recvSub.GasCostAmount) + assert.Nil(t, recvSub.ExecutionError) + assert.NotNil(t, recvSub.ResolvedAt) + + // Tick 3: ack confirmed (resolves SUCCESS) -> terminal. + h.engine.RunOnce(h.ctx) + p = h.getPacket(seq) + assert.Equal(t, store.RelayStatusCompleteWithAck, p.Status) + + ackSub := h.txSubmission(chainS, ackHash.Hex()) + assert.Equal(t, store.TxSubmissionStatusSuccess, ackSub.Status) + require.NotNil(t, ackSub.GasCostAmount) + assert.Equal(t, defaultGasCost, *ackSub.GasCostAmount) + + assertOrderedSubset(t, h.store.statusLog(), []store.RelayStatus{ + store.RelayStatusAwaitingSendFinality, + store.RelayStatusDeliverRecvPacket, + store.RelayStatusWaitForWriteAck, + store.RelayStatusAwaitingWriteAckFinality, + store.RelayStatusDeliverAckPacket, + store.RelayStatusCompleteWithAck, + }) + + assert.Len(t, h.store.submissionsOf(store.TxTypeRecvPacket), 1) + assert.Len(t, h.store.submissionsOf(store.TxTypeAckPacket), 1) + + // completed_ack on the source route; recv (dest) and ack (source) both SUCCESS + // with the receipt gas cost accrued. + h.assertMetrics(` +# HELP ibclink_relayer_packets_total Packets that reached a terminal relay state, by source route and outcome. +# TYPE ibclink_relayer_packets_total counter +ibclink_relayer_packets_total{outcome="completed_ack",route="1/clientS"} 1 +# HELP ibclink_relayer_tx_gas_cost_wei_total Total gas cost of resolved relay transactions in wei, by chain and tx type. +# TYPE ibclink_relayer_tx_gas_cost_wei_total counter +ibclink_relayer_tx_gas_cost_wei_total{chain_id="1",tx_type="ACK_PACKET"} 2.1e+13 +ibclink_relayer_tx_gas_cost_wei_total{chain_id="2",tx_type="RECV_PACKET"} 2.1e+13 +# HELP ibclink_relayer_tx_submissions_total Relay transactions resolved, by chain, tx type, and status. +# TYPE ibclink_relayer_tx_submissions_total counter +ibclink_relayer_tx_submissions_total{chain_id="1",status="SUCCESS",tx_type="ACK_PACKET"} 1 +ibclink_relayer_tx_submissions_total{chain_id="2",status="SUCCESS",tx_type="RECV_PACKET"} 1 +`, + "ibclink_relayer_packets_total", + "ibclink_relayer_tx_submissions_total", + "ibclink_relayer_tx_gas_cost_wei_total", + ) +} + +func TestErrorAckStillRelayed(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + h.engine.RunOnce(h.ctx) + recvHash := h.subDst.lastHash() + h.seedWriteAck(seq, recvHash.Hex(), universalErrorAckBytes) + + h.engine.RunOnce(h.ctx) // record error write ack + deliver ack + p := h.getPacket(seq) + require.NotNil(t, p.WriteAckStatus) + assert.Equal(t, store.WriteAckStatusError, *p.WriteAckStatus) + require.NotNil(t, p.AckTxHash, "error acks are still relayed") + + h.engine.RunOnce(h.ctx) + assert.Equal(t, store.RelayStatusCompleteWithAck, h.getPacket(seq).Status) +} + +func TestTimeoutPathComplete(t *testing.T) { + h := newHarness(t) + const seq = 1 + // Timeout already elapsed; the dest chain's block time is well past it. + h.seedPacket(seq, fixedNow.Add(-time.Hour)) + + h.engine.RunOnce(h.ctx) + require.Equal(t, 0, h.subDst.submitCount(), "no recv once timed out") + require.Equal(t, 1, h.subSrc.submitCount(), "timeout delivered on source") + timeoutHash := h.subSrc.lastHash() + p := h.getPacket(seq) + require.NotNil(t, p.TimeoutTxHash) + assert.Equal(t, store.RelayStatusDeliverTimeoutPacket, p.Status) + + h.engine.RunOnce(h.ctx) + assert.Equal(t, store.RelayStatusCompleteWithTimeout, h.getPacket(seq).Status) + assert.Equal(t, 0, h.subDst.submitCount(), "recv never attempted") + assert.Len(t, h.store.submissionsOf(store.TxTypeTimeoutPacket), 1) + + // The confirmed timeout tx resolved SUCCESS with gas. + sub := h.txSubmission(chainS, timeoutHash.Hex()) + assert.Equal(t, store.TxSubmissionStatusSuccess, sub.Status) + require.NotNil(t, sub.GasCostAmount) + assert.Equal(t, defaultGasCost, *sub.GasCostAmount) + + h.assertMetrics(` +# HELP ibclink_relayer_packets_total Packets that reached a terminal relay state, by source route and outcome. +# TYPE ibclink_relayer_packets_total counter +ibclink_relayer_packets_total{outcome="completed_timeout",route="1/clientS"} 1 +# HELP ibclink_relayer_tx_gas_cost_wei_total Total gas cost of resolved relay transactions in wei, by chain and tx type. +# TYPE ibclink_relayer_tx_gas_cost_wei_total counter +ibclink_relayer_tx_gas_cost_wei_total{chain_id="1",tx_type="TIMEOUT_PACKET"} 2.1e+13 +# HELP ibclink_relayer_tx_submissions_total Relay transactions resolved, by chain, tx type, and status. +# TYPE ibclink_relayer_tx_submissions_total counter +ibclink_relayer_tx_submissions_total{chain_id="1",status="SUCCESS",tx_type="TIMEOUT_PACKET"} 1 +`, + "ibclink_relayer_packets_total", + "ibclink_relayer_tx_submissions_total", + "ibclink_relayer_tx_gas_cost_wei_total", + ) +} + +func TestAlreadyReceivedShortCircuit(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Receipt already present on dest (delivered out of band). + h.dstClient.setCommitment(receiptPath(seq), nonzeroCommitment) + recvTx := common.BigToHash(new(big.Int).SetUint64(0xdead)).Hex() + h.seedWriteAck(seq, recvTx, successAckBytes) + + // Tick 1: backfill recv + write ack, no recv submission. + h.engine.RunOnce(h.ctx) + assert.Equal(t, 0, h.subDst.submitCount(), "no recv submitted") + p := h.getPacket(seq) + require.NotNil(t, p.RecvTxHash) + assert.Equal(t, recvTx, *p.RecvTxHash) + require.NotNil(t, p.WriteAckStatus) + + // Subsequent ticks carry it through ack delivery to completion. + h.engine.RunOnce(h.ctx) + h.engine.RunOnce(h.ctx) + assert.Equal(t, 0, h.subDst.submitCount(), "recv never submitted") + assert.Equal(t, store.RelayStatusCompleteWithAck, h.getPacket(seq).Status) +} + +// TestSourceCommitmentGoneNoEventInvariantFailure proves that a cleared source +// commitment with no discoverable ack/timeout event terminalizes as an explicit +// invariant failure (never a fabricated complete-with-ack). +func TestSourceCommitmentGoneNoEventInvariantFailure(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + // Source commitment cleared (acked/timed out out of band) but no event exists. + h.srcClient.setCommitment(srcCommitmentPath(seq), [32]byte{}) + + h.engine.RunOnce(h.ctx) + + assert.Equal(t, store.RelayStatusFailedInvariant, h.getPacket(seq).Status) + assert.Equal(t, 0, h.subDst.submitCount()) + assert.Equal(t, 0, h.subSrc.submitCount()) + assert.Empty(t, h.store.submissionsOf(store.TxTypeRecvPacket)) +} + +// TestSourceGoneClassifiedAsAck proves an out-of-band completion is classified +// by the ACTUAL source event: an AckPacket event terminalizes as ack and records +// the external ack tx hash. +func TestSourceGoneClassifiedAsAck(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + h.srcClient.setCommitment(srcCommitmentPath(seq), [32]byte{}) + + h.srcClient.mu.Lock() + h.srcClient.ackScan = []chains.PacketEvent{{ + Kind: chains.KindAckPacket, Height: 500, TxHash: "0xackext", BlockTime: fixedNow, + Packet: chains.Packet{Sequence: seq, SourceClient: clientS, DestClient: clientD}, + }} + h.srcClient.mu.Unlock() + + h.engine.RunOnce(h.ctx) + + p := h.getPacket(seq) + assert.Equal(t, store.RelayStatusCompleteWithAck, p.Status) + require.NotNil(t, p.AckTxHash) + assert.Equal(t, "0xackext", *p.AckTxHash) + assert.Equal(t, 0, h.subDst.submitCount()) + assert.Equal(t, 0, h.subSrc.submitCount()) +} + +// TestSourceGoneErrorAckBackfill proves an out-of-band completion whose observed +// acknowledgement equals the universal error ack is persisted as an ERROR write +// ack (mirroring the in-band classification) so the unified status derivation +// reports error_ack instead of an ordinary completion. +func TestSourceGoneErrorAckBackfill(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + h.srcClient.setCommitment(srcCommitmentPath(seq), [32]byte{}) + + h.srcClient.mu.Lock() + h.srcClient.ackScan = []chains.PacketEvent{{ + Kind: chains.KindAckPacket, Height: 500, TxHash: "0xackext", BlockTime: fixedNow, + Packet: chains.Packet{Sequence: seq, SourceClient: clientS, DestClient: clientD}, + Acks: [][]byte{universalErrorAckBytes}, + }} + h.srcClient.mu.Unlock() + + h.engine.RunOnce(h.ctx) + + p := h.getPacket(seq) + assert.Equal(t, store.RelayStatusCompleteWithAck, p.Status) + require.NotNil(t, p.AckTxHash) + assert.Equal(t, "0xackext", *p.AckTxHash) + require.NotNil(t, p.WriteAckStatus, "out-of-band ack must record a write-ack status") + assert.Equal(t, store.WriteAckStatusError, *p.WriteAckStatus) + assert.Nil(t, p.WriteAckTxHash, "source-gone ack must not fabricate a dest write-ack tx hash") +} + +// TestSourceGoneClassifiedAsTimeout proves a TimeoutPacket event terminalizes as +// timeout (not a fabricated ack) and records the external timeout tx hash. +func TestSourceGoneClassifiedAsTimeout(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + h.srcClient.setCommitment(srcCommitmentPath(seq), [32]byte{}) + + h.srcClient.mu.Lock() + h.srcClient.timeoutScan = []chains.PacketEvent{{ + Kind: chains.KindTimeoutPacket, Height: 500, TxHash: "0xtoext", BlockTime: fixedNow, + Packet: chains.Packet{Sequence: seq, SourceClient: clientS, DestClient: clientD}, + }} + h.srcClient.mu.Unlock() + + h.engine.RunOnce(h.ctx) + + p := h.getPacket(seq) + assert.Equal(t, store.RelayStatusCompleteWithTimeout, p.Status) + require.NotNil(t, p.TimeoutTxHash) + assert.Equal(t, "0xtoext", *p.TimeoutTxHash) +} + +// TestBackfillWriteAckPagesAcrossTicks proves the out-of-band recv backfill scans +// backward in pages across ticks so an old write ack (below the first tick's page +// budget) is eventually found instead of stranding the packet forever. +func TestBackfillWriteAckPagesAcrossTicks(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Receipt present on dest (received out of band); the write ack sits in a very + // old block, below the first tick's 5-page (50k-block) budget from latest. + h.dstClient.setCommitment(receiptPath(seq), nonzeroCommitment) + h.dstClient.mu.Lock() + h.dstClient.latest = 60_000 + recvTx := "0xoldrecv" + h.dstClient.writeAckScan = []chains.PacketEvent{{ + Kind: chains.KindWriteAck, Height: 5, TxHash: recvTx, BlockTime: fixedNow, + Packet: h.packet(seq, fixedNow.Add(time.Hour)), + Acks: [][]byte{successAckBytes}, + }} + h.dstClient.mu.Unlock() + h.dstClient.setTxEvents(recvTx, []chains.PacketEvent{{ + Kind: chains.KindWriteAck, Height: 5, TxHash: recvTx, BlockTime: fixedNow, + Packet: h.packet(seq, fixedNow.Add(time.Hour)), Acks: [][]byte{successAckBytes}, + }}) + + // Tick 1: only the top 50k blocks are scanned; the old write ack is not found. + h.engine.RunOnce(h.ctx) + assert.Nil(t, h.getPacket(seq).RecvTxHash, "not found within the first tick's page budget") + + // Tick 2: paging resumes downward and finds it. + h.engine.RunOnce(h.ctx) + p := h.getPacket(seq) + require.NotNil(t, p.RecvTxHash, "old write ack found after paging resumes") + assert.Equal(t, recvTx, *p.RecvTxHash) +} + +func TestRecvRetryResubmitsNewTx(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Recv never lands; ShouldRetry reports the tx expired. + h.subDst.defaultIncluded = false + h.subDst.shouldRetry = true + + h.engine.RunOnce(h.ctx) // deliver recv (attempt 1) + require.Equal(t, 1, h.subDst.submitCount()) + firstHash := h.subDst.lastHash() + + h.engine.RunOnce(h.ctx) // detect stuck -> resolve EXPIRED + clear + assert.Nil(t, h.getPacket(seq).RecvTxHash, "stuck recv tx cleared") + + // The expired tx never landed: resolved EXPIRED with no gas or error. + sub := h.txSubmission(chainD, firstHash.Hex()) + assert.Equal(t, store.TxSubmissionStatusExpired, sub.Status) + assert.Nil(t, sub.GasCostAmount, "expired tx never landed, no gas") + assert.Nil(t, sub.ExecutionError) + + // EXPIRED submission counted, no gas accrued, and expiry is not terminal for + // the packet so no packet outcome is recorded. + h.assertMetrics(` +# HELP ibclink_relayer_tx_submissions_total Relay transactions resolved, by chain, tx type, and status. +# TYPE ibclink_relayer_tx_submissions_total counter +ibclink_relayer_tx_submissions_total{chain_id="2",status="EXPIRED",tx_type="RECV_PACKET"} 1 +`, "ibclink_relayer_tx_submissions_total") + h.assertNoMetric("ibclink_relayer_tx_gas_cost_wei_total") + h.assertNoMetric("ibclink_relayer_packets_total") + + h.engine.RunOnce(h.ctx) // re-deliver recv (attempt 2) + require.Equal(t, 2, h.subDst.submitCount(), "resubmitted") + assert.NotEqual(t, firstHash, h.subDst.lastHash(), "new tx hash") + assert.Len(t, h.store.submissionsOf(store.TxTypeRecvPacket), 2, "a new submission per attempt") +} + +func TestRecvRevertedReceiptImmediateClear(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + h.engine.RunOnce(h.ctx) // deliver recv + recvHash := h.subDst.lastHash() + + // Receipt present but reverted (a reverted tx still burns gas). + h.subDst.included[recvHash] = true + h.subDst.succeeded[recvHash] = false + + h.engine.RunOnce(h.ctx) // reverted -> resolve FAILED + clear + assert.Nil(t, h.getPacket(seq).RecvTxHash, "reverted recv cleared immediately") + + sub := h.txSubmission(chainD, recvHash.Hex()) + assert.Equal(t, store.TxSubmissionStatusFailed, sub.Status) + require.NotNil(t, sub.ExecutionError) + assert.Equal(t, "transaction reverted", *sub.ExecutionError) + require.NotNil(t, sub.GasCostAmount) + assert.Equal(t, defaultGasCost, *sub.GasCostAmount) + + // FAILED submission with gas; a revert is not terminal for the packet. + h.assertMetrics(` +# HELP ibclink_relayer_tx_gas_cost_wei_total Total gas cost of resolved relay transactions in wei, by chain and tx type. +# TYPE ibclink_relayer_tx_gas_cost_wei_total counter +ibclink_relayer_tx_gas_cost_wei_total{chain_id="2",tx_type="RECV_PACKET"} 2.1e+13 +# HELP ibclink_relayer_tx_submissions_total Relay transactions resolved, by chain, tx type, and status. +# TYPE ibclink_relayer_tx_submissions_total counter +ibclink_relayer_tx_submissions_total{chain_id="2",status="FAILED",tx_type="RECV_PACKET"} 1 +`, + "ibclink_relayer_tx_submissions_total", + "ibclink_relayer_tx_gas_cost_wei_total", + ) + h.assertNoMetric("ibclink_relayer_packets_total") +} + +func TestSendFinalityGating(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Recv-side attestor set cannot yet attest the send-event height. + h.recvPG.setHeight(sendHeight - 1) + + h.engine.RunOnce(h.ctx) + assert.Equal(t, 0, h.subDst.submitCount(), "nothing submitted below finality") + assert.Equal(t, store.RelayStatusAwaitingSendFinality, h.getPacket(seq).Status) + + // Finality catches up. + h.recvPG.setHeight(attestHeight) + h.engine.RunOnce(h.ctx) + assert.Equal(t, 1, h.subDst.submitCount(), "delivered once finalized") +} + +func TestBatchSingleSubmissionForManyPackets(t *testing.T) { + h := newHarness(t) + for seq := uint64(1); seq <= 3; seq++ { + h.seedPacket(seq, fixedNow.Add(time.Hour)) + } + + h.engine.RunOnce(h.ctx) + + require.Equal(t, 1, h.subDst.submitCount(), "one tx for the whole batch") + require.Equal(t, 1, h.builderDst.callCount(), "one multicall built") + call := h.builderDst.calls[0] + require.NotNil(t, call.update, "one updateClient") + assert.Equal(t, clientD, call.update.ClientID) + require.Len(t, call.items, 3, "three recv items") + for _, item := range call.items { + assert.Equal(t, chains.RelayRecv, item.Kind) + } +} + +func TestBatchChunksAtBatchSize(t *testing.T) { + // Destination (recv) batch size of 2 with 3 packets => two chunks. + batch := 2 + h := newHarness(t, config.RelayerChainOverride{ChainID: chainD, PacketBatchSize: &batch}) + for seq := uint64(1); seq <= 3; seq++ { + h.seedPacket(seq, fixedNow.Add(time.Hour)) + } + + h.engine.RunOnce(h.ctx) + + assert.Equal(t, 2, h.subDst.submitCount(), "two chunked submissions") + assert.Equal(t, 2, h.builderDst.callCount()) +} + +func TestOneAttestedHeightPerBatch(t *testing.T) { + h := newHarness(t) + for seq := uint64(1); seq <= 3; seq++ { + h.seedPacket(seq, fixedNow.Add(time.Hour)) + } + + h.engine.RunOnce(h.ctx) + + require.Equal(t, 1, h.builderDst.callCount()) + items := h.builderDst.calls[0].items + require.Len(t, items, 3) + for _, item := range items { + assert.Equal(t, attestHeight, item.ProofHeight, "all items pinned to one height") + } + + h.recvPG.mu.Lock() + defer h.recvPG.mu.Unlock() + assert.Equal(t, 1, h.recvPG.stateProofCalls, "exactly one state proof") + assert.Len(t, h.recvPG.packetCalls, 1, "exactly one packet-proofs call") +} + +func TestUnknownRouteSkippedAndIsolated(t *testing.T) { + h := newHarness(t) + // A valid packet on the configured route. + h.seedPacket(1, fixedNow.Add(time.Hour)) + + // A packet whose source client has no configured route. + unknownTx := common.BigToHash(new(big.Int).SetUint64(0xbeef)).Hex() + require.NoError(t, h.db.CreatePacket(h.ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainS, + DestinationChainID: chainD, + SourceTxHash: unknownTx, + SourceTxTime: fixedNow, + PacketSequenceNumber: 99, + PacketSourceClientID: "no-such-client", + PacketDestinationClientID: clientD, + PacketTimeoutTimestamp: fixedNow.Add(time.Hour), + })) + + // Does not panic; the known packet still advances. + assert.NotPanics(t, func() { h.engine.RunOnce(h.ctx) }) + assert.Equal(t, 1, h.subDst.submitCount(), "known route processed") + + unknown, err := h.db.GetPacket(h.ctx, store.PacketKey{ + SourceChainID: chainS, + PacketSequenceNumber: 99, + PacketSourceClientID: "no-such-client", + }) + require.NoError(t, err) + assert.Equal(t, store.RelayStatusPending, unknown.Status, "unknown route untouched") +} + +func TestPerRouteErrorIsolation(t *testing.T) { + h := newHarness(t) + // Route 1/clientS: source client errors while loading events. + badTx := h.seedPacket(1, fixedNow.Add(time.Hour)) + h.srcClient.mu.Lock() + h.srcClient.txEventsErr[badTx] = fmt.Errorf("rpc down") + h.srcClient.mu.Unlock() + + // Route 2/clientD: seed a healthy packet sent on chain D. + h.seedReverseRoutePacket(2) + + assert.NotPanics(t, func() { h.engine.RunOnce(h.ctx) }) + // The failing route submitted nothing; the healthy route delivered its recv + // on chain S (its destination). + assert.Equal(t, 0, h.subDst.submitCount(), "failing route stalled") + assert.Equal(t, 1, h.subSrc.submitCount(), "healthy route proceeded") +} + +// TestStatusWriteFailureGatesDelivery proves persistence gates delivery: when +// the DELIVER_RECV_PACKET status write fails, no recv tx is submitted, and once +// the store recovers the packet delivers on the next tick. +func TestStatusWriteFailureGatesDelivery(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Fail the DELIVER status write on the first tick only. + h.store.failStatus(store.RelayStatusDeliverRecvPacket, 1) + + h.engine.RunOnce(h.ctx) + assert.Equal(t, 0, h.subDst.submitCount(), "delivery aborted when DELIVER status not persisted") + assert.NotEqual(t, store.RelayStatusDeliverRecvPacket, h.getPacket(seq).Status) + + // Next tick: the status write succeeds and the recv is delivered. + h.engine.RunOnce(h.ctx) + assert.Equal(t, 1, h.subDst.submitCount(), "delivered once the status persisted") + assert.Equal(t, store.RelayStatusDeliverRecvPacket, h.getPacket(seq).Status) +} + +// TestRecordSubmissionAtomicRollback proves every post-broadcast write is +// all-or-nothing: when one packet's tx-hash persist fails inside the transaction, +// the whole record (all packets' hashes, the submission row, the links) rolls +// back. The tx is already broadcast, so nothing is retried inline; the next tick +// re-submits and, once the store recovers, commits the whole record atomically. +func TestRecordSubmissionAtomicRollback(t *testing.T) { + h := newHarness(t) + h.seedPacket(1, fixedNow.Add(time.Hour)) + h.seedPacket(2, fixedNow.Add(time.Hour)) + + // Packet 2's persist fails once. Packet 1's recv hash is written earlier in the + // same transaction, so failing the LATER sibling write must roll BOTH back — + // proving the earlier successful write is undone, not merely that packet 2 was + // never reached. + h.store.failRecvTx(2, 1) + + h.engine.RunOnce(h.ctx) + + // The batch tx was still broadcast, but the whole post-broadcast record rolled + // back: no submission row, and neither packet's recv hash landed — including + // packet 1's, which had already been persisted before packet 2's write failed. + assert.Equal(t, 1, h.subDst.submitCount(), "tx broadcast despite the rollback") + assert.Empty(t, h.store.submissionsOf(store.TxTypeRecvPacket), "submission rolled back") + assert.Nil(t, h.getPacket(1).RecvTxHash, "earlier sibling's recv hash rolled back with the failed later write") + assert.Nil(t, h.getPacket(2).RecvTxHash, "failing packet's recv hash rolled back") + assert.Equal(t, 1, h.store.recvTxAttempts(2), "no inline retry; persist attempted once per tick") + + // Next tick: the tick loop is the retry. Persist succeeds and the whole record + // commits atomically for both packets. + h.engine.RunOnce(h.ctx) + assert.Equal(t, 2, h.subDst.submitCount(), "re-submitted next tick") + assert.Len(t, h.store.submissionsOf(store.TxTypeRecvPacket), 1, "record committed on retry") + assert.NotNil(t, h.getPacket(1).RecvTxHash, "both packets persisted on retry") + assert.NotNil(t, h.getPacket(2).RecvTxHash) +} + +// TestBatchRecvNoOpClearsAndConverges proves a packet whose batched recv no-op'd +// on chain (the confirmed recv tx carries a write ack for its sibling but not for +// this packet, because its receipt was already set out of band) does not strand +// in WAIT_FOR_WRITE_ACK. Its recv tx is cleared, it re-enters fresh evaluation, +// backfills the real recv + write ack from the dest scan, and converges to a +// truthful completion — while the healthy sibling completes normally. +func TestBatchRecvNoOpClearsAndConverges(t *testing.T) { + h := newHarness(t) + h.seedPacket(1, fixedNow.Add(time.Hour)) // healthy + h.seedPacket(2, fixedNow.Add(time.Hour)) // no-op on chain + + // Tick 1: both packets ride one batched recv tx. + h.engine.RunOnce(h.ctx) + require.Equal(t, 1, h.subDst.submitCount(), "one batch recv tx for both packets") + recvHash := h.subDst.lastHash().Hex() + require.NotNil(t, h.getPacket(1).RecvTxHash) + require.NotNil(t, h.getPacket(2).RecvTxHash) + assert.Equal(t, recvHash, *h.getPacket(1).RecvTxHash) + assert.Equal(t, recvHash, *h.getPacket(2).RecvTxHash) + + // The confirmed batch recv tx emitted a write ack for packet 1 only; packet 2 + // no-op'd on chain because its receipt was already set out of band. + h.dstClient.setTxEvents(recvHash, []chains.PacketEvent{{ + Kind: chains.KindWriteAck, Height: writeAckHeight, BlockTime: fixedNow, TxHash: recvHash, + Packet: h.packet(1, fixedNow.Add(time.Hour)), Acks: [][]byte{successAckBytes}, + }}) + // Packet 2's out-of-band receipt is now visible on dest, and its real write ack + // lives in a separate external recv tx discoverable via the dest range scan. + h.dstClient.setCommitment(receiptPath(2), nonzeroCommitment) + externalRecv := common.BigToHash(new(big.Int).SetUint64(0xe2)).Hex() + h.seedWriteAck(2, externalRecv, successAckBytes) + + // Tick 2: packet 1 records its write ack and delivers its ack; packet 2 finds + // no write ack in the batch tx and clears its recv, leaving WAIT_FOR_WRITE_ACK. + h.engine.RunOnce(h.ctx) + assert.Equal(t, store.RelayStatusDeliverAckPacket, h.getPacket(1).Status, "healthy sibling advances to ack") + p2 := h.getPacket(2) + assert.Nil(t, p2.RecvTxHash, "no-op packet's recv tx cleared") + assert.NotEqual(t, store.RelayStatusWaitForWriteAck, p2.Status, "no-op packet left WAIT_FOR_WRITE_ACK") + + // Subsequent ticks: packet 1 completes; packet 2 backfills the real recv + + // write ack and converges instead of stranding forever. + for i := 0; i < 4; i++ { + h.engine.RunOnce(h.ctx) + } + assert.Equal(t, store.RelayStatusCompleteWithAck, h.getPacket(1).Status) + + converged := h.getPacket(2) + assert.Equal(t, store.RelayStatusCompleteWithAck, converged.Status, "no-op packet converged") + require.NotNil(t, converged.RecvTxHash) + assert.Equal(t, externalRecv, *converged.RecvTxHash, "converged on the real external recv tx, not the batch tx") +} + +// TestAckSourceGoneRoutedThroughBackfill proves that when a packet reaches ack +// delivery but its source commitment was cleared out of band, the engine does not +// fabricate a completion: it routes through the source-gone backfill, discovers +// the ACTUAL ack event, and records the real external ack tx hash. +func TestAckSourceGoneRoutedThroughBackfill(t *testing.T) { + h := newHarness(t) + const seq = 1 + h.seedPacket(seq, fixedNow.Add(time.Hour)) + + // Tick 1: deliver recv. + h.engine.RunOnce(h.ctx) + recvHash := h.subDst.lastHash().Hex() + h.seedWriteAck(seq, recvHash, successAckBytes) + + // Before the ack is delivered, the source commitment is cleared out of band and + // the real ack event is discoverable via the source scan. + h.srcClient.setCommitment(srcCommitmentPath(seq), [32]byte{}) + h.srcClient.mu.Lock() + h.srcClient.ackScan = []chains.PacketEvent{{ + Kind: chains.KindAckPacket, Height: 500, TxHash: "0xackext", BlockTime: fixedNow, + Packet: chains.Packet{Sequence: seq, SourceClient: clientS, DestClient: clientD}, + }} + h.srcClient.mu.Unlock() + + // Tick 2: recv confirmed, write ack recorded, source-gone guard routes through + // backfill which records the real ack tx and completes with ack. + h.engine.RunOnce(h.ctx) + + p := h.getPacket(seq) + assert.Equal(t, store.RelayStatusCompleteWithAck, p.Status) + require.NotNil(t, p.AckTxHash, "the real external ack tx is recorded, not fabricated") + assert.Equal(t, "0xackext", *p.AckTxHash) + assert.Equal(t, 0, h.subSrc.submitCount(), "no ack submitted; it was already acked out of band") +} + +func TestGracefulShutdown(t *testing.T) { + h := newHarness(t) + h.seedPacket(1, fixedNow.Add(time.Hour)) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- h.engine.Run(ctx) }() + + cancel() + + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after context cancellation") + } +} + +// seedReverseRoutePacket seeds a packet that travels D -> S (route 2/clientD), +// received on chain S. Used for cross-route isolation. +func (h *harness) seedReverseRoutePacket(seq uint64) { + h.t.Helper() + srcTx := common.BigToHash(new(big.Int).SetUint64(seq + 0xcafe)).Hex() + + h.dstClient.setTxEvents(srcTx, []chains.PacketEvent{{ + Kind: chains.KindSendPacket, + Height: sendHeight, + BlockTime: fixedNow, + TxHash: srcTx, + Packet: chains.Packet{ + Sequence: seq, SourceClient: clientD, DestClient: clientS, + TimeoutTimestamp: uint64(fixedNow.Add(time.Hour).Unix()), //nolint:gosec + Payloads: []chains.Payload{{SourcePort: "transfer", DestPort: "transfer"}}, + }, + }}) + // Source commitment lives on chain D for this direction. + h.dstClient.setCommitment(attestation.PathHash(attestation.PacketCommitmentPath(clientD, seq)), nonzeroCommitment) + + require.NoError(h.t, h.db.CreatePacket(h.ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainD, + DestinationChainID: chainS, + SourceTxHash: srcTx, + SourceTxTime: fixedNow, + PacketSequenceNumber: seq, + PacketSourceClientID: clientD, + PacketDestinationClientID: clientS, + PacketTimeoutTimestamp: fixedNow.Add(time.Hour), + })) +} + +// assertMetrics asserts the registry's series for the named metrics exactly match +// the expected Prometheus text exposition. +func (h *harness) assertMetrics(expected string, names ...string) { + h.t.Helper() + require.NoError(h.t, testutil.GatherAndCompare(h.metrics.Registry(), strings.NewReader(expected), names...)) +} + +// assertNoMetric asserts the named metric has no series at all. +func (h *harness) assertNoMetric(name string) { + h.t.Helper() + count, err := testutil.GatherAndCount(h.metrics.Registry(), name) + require.NoError(h.t, err) + assert.Zerof(h.t, count, "expected no %s series", name) +} + +// TestMetricsBatchTxCountedOnce proves that when many packets share a single +// batch tx, the tx-submission and gas metrics count the transaction exactly once +// even though every packet re-resolves the shared tx every tick. +func TestMetricsBatchTxCountedOnce(t *testing.T) { + h := newHarness(t) + for seq := uint64(1); seq <= 3; seq++ { + h.seedPacket(seq, fixedNow.Add(time.Hour)) + } + + h.engine.RunOnce(h.ctx) // deliver one recv tx for all three packets + require.Equal(t, 1, h.subDst.submitCount(), "single shared tx for the batch") + + // Resolve the shared recv tx repeatedly: each packet re-resolves it every tick + // (no write ack seeded, so they stall at WAIT_FOR_WRITE_ACK). + h.engine.RunOnce(h.ctx) + h.engine.RunOnce(h.ctx) + + // Counted exactly once despite 3 packets * multiple ticks of resolution. + h.assertMetrics(` +# HELP ibclink_relayer_tx_gas_cost_wei_total Total gas cost of resolved relay transactions in wei, by chain and tx type. +# TYPE ibclink_relayer_tx_gas_cost_wei_total counter +ibclink_relayer_tx_gas_cost_wei_total{chain_id="2",tx_type="RECV_PACKET"} 2.1e+13 +# HELP ibclink_relayer_tx_submissions_total Relay transactions resolved, by chain, tx type, and status. +# TYPE ibclink_relayer_tx_submissions_total counter +ibclink_relayer_tx_submissions_total{chain_id="2",status="SUCCESS",tx_type="RECV_PACKET"} 1 +`, + "ibclink_relayer_tx_submissions_total", + "ibclink_relayer_tx_gas_cost_wei_total", + ) +} + +// assertOrderedSubset asserts want appears as an ordered (not necessarily +// contiguous) subsequence of got. +func assertOrderedSubset(t *testing.T, got, want []store.RelayStatus) { + t.Helper() + i := 0 + for _, s := range got { + if i < len(want) && s == want[i] { + i++ + } + } + assert.Equalf(t, len(want), i, "missing ordered status %v in log %v", want, got) +} diff --git a/link/internal/relay/engine/fakes_test.go b/link/internal/relay/engine/fakes_test.go new file mode 100644 index 000000000..f08ffed39 --- /dev/null +++ b/link/internal/relay/engine/fakes_test.go @@ -0,0 +1,549 @@ +package engine_test + +import ( + "context" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/chains/manager" + "github.com/cosmos/ibc/link/internal/relay/engine" + "github.com/cosmos/ibc/link/internal/relay/proofgen" + "github.com/cosmos/ibc/link/internal/store" +) + +// Compile-time proof that the production types satisfy the engine's interfaces. +var ( + _ engine.Submitter = (*evm.Submitter)(nil) + _ engine.TxBuilder = (*evm.TxBuilder)(nil) + _ engine.ProofGenerator = (*proofgen.Generator)(nil) + _ engine.Store = (*store.SqliteDB)(nil) + _ engine.ChainClients = (*manager.ClientManager)(nil) +) + +// fakeChainClient is a hand-rolled chains.Client backed by in-memory maps. +type fakeChainClient struct { + mu sync.Mutex + + latest uint64 + commitments map[[32]byte][32]byte + headerTimes map[uint64]time.Time + defaultHeaderTime time.Time + txEvents map[string][]chains.PacketEvent + writeAckScan []chains.PacketEvent + ackScan []chains.PacketEvent + timeoutScan []chains.PacketEvent + txEventsErr map[string]error +} + +func newFakeChainClient() *fakeChainClient { + return &fakeChainClient{ + latest: 1_000, + commitments: make(map[[32]byte][32]byte), + headerTimes: make(map[uint64]time.Time), + defaultHeaderTime: time.Unix(1_000_000, 0).UTC(), + txEvents: make(map[string][]chains.PacketEvent), + txEventsErr: make(map[string]error), + } +} + +func (f *fakeChainClient) setCommitment(path [32]byte, val [32]byte) { + f.mu.Lock() + defer f.mu.Unlock() + f.commitments[path] = val +} + +func (f *fakeChainClient) setTxEvents(hash string, events []chains.PacketEvent) { + f.mu.Lock() + defer f.mu.Unlock() + f.txEvents[hash] = events +} + +func (f *fakeChainClient) TxPacketEvents(_ context.Context, txHash []byte) ([]chains.PacketEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + key := common.BytesToHash(txHash).Hex() + if err := f.txEventsErr[key]; err != nil { + return nil, err + } + + return f.txEvents[key], nil +} + +func (f *fakeChainClient) SendPacketEvents(_ context.Context, _, _ uint64) ([]chains.PacketEvent, error) { + return nil, nil +} + +func (f *fakeChainClient) WriteAckEvents(_ context.Context, from, to uint64) ([]chains.PacketEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return eventsInRange(f.writeAckScan, from, to), nil +} + +func (f *fakeChainClient) AckPacketEvents(_ context.Context, from, to uint64) ([]chains.PacketEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return eventsInRange(f.ackScan, from, to), nil +} + +func (f *fakeChainClient) TimeoutPacketEvents(_ context.Context, from, to uint64) ([]chains.PacketEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return eventsInRange(f.timeoutScan, from, to), nil +} + +// eventsInRange returns the events whose height falls within [from, to], so the +// engine's paged backward scan can be exercised realistically. +func eventsInRange(events []chains.PacketEvent, from, to uint64) []chains.PacketEvent { + out := make([]chains.PacketEvent, 0, len(events)) + for _, ev := range events { + if ev.Height >= from && ev.Height <= to { + out = append(out, ev) + } + } + + return out +} + +func (f *fakeChainClient) LatestHeight(_ context.Context) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.latest, nil +} + +func (f *fakeChainClient) FinalizedHeight(_ context.Context, _ *uint64) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.latest, nil +} + +func (f *fakeChainClient) HeaderTimestamp(_ context.Context, height uint64) (time.Time, error) { + f.mu.Lock() + defer f.mu.Unlock() + if t, ok := f.headerTimes[height]; ok { + return t, nil + } + + return f.defaultHeaderTime, nil +} + +func (f *fakeChainClient) GetCommitment(_ context.Context, path [32]byte, _ uint64) ([32]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.commitments[path], nil +} + +// submitCall captures a single Submit invocation. +type submitCall struct { + to common.Address + data []byte + hash common.Hash +} + +// fakeSubmitter is a hand-rolled engine.Submitter capturing every submission. +type fakeSubmitter struct { + mu sync.Mutex + + from common.Address + base int64 + count int64 + submits []submitCall + + submitErr error + defaultIncluded bool + defaultSucceeded bool + included map[common.Hash]bool + succeeded map[common.Hash]bool + receipts map[common.Hash]*types.Receipt + defaultReceipt *types.Receipt + receiptErr error + shouldRetry bool + shouldRetryErr error +} + +func newFakeSubmitter(base int64) *fakeSubmitter { + return &fakeSubmitter{ + from: common.BigToAddress(big.NewInt(base)), + base: base, + included: make(map[common.Hash]bool), + succeeded: make(map[common.Hash]bool), + receipts: make(map[common.Hash]*types.Receipt), + // Default receipt used for gas accounting on resolution: 21000 gas at + // 1 gwei effective price -> 21_000_000_000_000 wei. + defaultReceipt: &types.Receipt{ + GasUsed: 21_000, + EffectiveGasPrice: big.NewInt(1_000_000_000), + }, + } +} + +func (f *fakeSubmitter) Submit(_ context.Context, to common.Address, data []byte) (common.Hash, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.submitErr != nil { + return common.Hash{}, f.submitErr + } + + f.count++ + hash := common.BigToHash(big.NewInt(f.base + f.count)) + f.submits = append(f.submits, submitCall{to: to, data: data, hash: hash}) + + return hash, nil +} + +// Expired maps to the retry/expiry decision for a not-yet-included tx; shouldRetry +// mirrors the old ShouldRetry knob. +func (f *fakeSubmitter) Expired(_ context.Context, _ time.Time, _ time.Duration) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.shouldRetry, f.shouldRetryErr +} + +// Receipt is the single read that drives in-flight resolution: it returns (nil, +// nil) when the tx is not included, and otherwise a receipt whose Status reflects +// the included/succeeded knobs and whose gas comes from the (optionally +// per-hash-overridden) default receipt. +func (f *fakeSubmitter) Receipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.receiptErr != nil { + return nil, f.receiptErr + } + + included := f.defaultIncluded + if v, ok := f.included[hash]; ok { + included = v + } + if !included { + return nil, nil + } + + succeeded := f.defaultSucceeded + if v, ok := f.succeeded[hash]; ok { + succeeded = v + } + + base := f.defaultReceipt + if r, ok := f.receipts[hash]; ok { + base = r + } + + receipt := *base + if succeeded { + receipt.Status = types.ReceiptStatusSuccessful + } else { + receipt.Status = types.ReceiptStatusFailed + } + + return &receipt, nil +} + +func (f *fakeSubmitter) From() common.Address { + return f.from +} + +func (f *fakeSubmitter) submitCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.submits) +} + +func (f *fakeSubmitter) lastHash() common.Hash { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.submits) == 0 { + return common.Hash{} + } + + return f.submits[len(f.submits)-1].hash +} + +// buildCall captures a single BuildRelayTx invocation. +type buildCall struct { + update *chains.ClientUpdate + items []chains.PacketRelayItem +} + +// fakeTxBuilder is a hand-rolled engine.TxBuilder capturing every build. +type fakeTxBuilder struct { + mu sync.Mutex + calls []buildCall + to common.Address +} + +func newFakeTxBuilder() *fakeTxBuilder { + return &fakeTxBuilder{to: common.BigToAddress(big.NewInt(0xabcd))} +} + +func (f *fakeTxBuilder) BuildRelayTx( + update *chains.ClientUpdate, + items []chains.PacketRelayItem, +) (common.Address, []byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, buildCall{update: update, items: append([]chains.PacketRelayItem(nil), items...)}) + + return f.to, []byte("calldata"), nil +} + +func (f *fakeTxBuilder) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.calls) +} + +// packetProofsCall captures a single PacketProofs invocation. +type packetProofsCall struct { + height uint64 + kind chains.ProofKind + packets []chains.Packet +} + +// fakeProofGen is a hand-rolled engine.ProofGenerator. +type fakeProofGen struct { + mu sync.Mutex + + height uint64 + stateProofCalls int + packetCalls []packetProofsCall + err error +} + +func newFakeProofGen(height uint64) *fakeProofGen { + return &fakeProofGen{height: height} +} + +func (f *fakeProofGen) setHeight(h uint64) { + f.mu.Lock() + defer f.mu.Unlock() + f.height = h +} + +func (f *fakeProofGen) PacketProofs( + _ context.Context, + _ uint64, + kind chains.ProofKind, + packets []chains.Packet, +) ([][]byte, uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, 0, f.err + } + + f.packetCalls = append(f.packetCalls, packetProofsCall{height: f.height, kind: kind, packets: packets}) + proofs := make([][]byte, len(packets)) + for i := range proofs { + proofs[i] = []byte("proof") + } + + return proofs, f.height, nil +} + +func (f *fakeProofGen) StateProof(_ context.Context, _ uint64) ([]byte, uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, 0, f.err + } + + f.stateProofCalls++ + + return []byte("state"), f.height, nil +} + +func (f *fakeProofGen) LatestHeight(_ context.Context) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.height, nil +} + +// fakeChainClients resolves a chains.Client by chain id. +type fakeChainClients struct { + clients map[string]chains.Client +} + +func (f *fakeChainClients) GetClient(chainID string) (chains.Client, error) { + c, ok := f.clients[chainID] + if !ok { + return nil, errNoClient(chainID) + } + + return c, nil +} + +type errNoClient string + +func (e errNoClient) Error() string { return "no client for chain " + string(e) } + +// recordingStore wraps an engine.Store, recording status transitions and tx +// submissions for assertions while delegating persistence to the real store. +type recordingStore struct { + engine.Store + + mu sync.Mutex + statuses []store.RelayStatus + submissions []store.CreateTxSubmission + + // statusFail holds remaining injected UpdatePacketStatus failures per status. + statusFail map[store.RelayStatus]int + // recvTxFail holds remaining injected SetPacketRecvTx failures per sequence. + recvTxFail map[uint64]int + // recvTxCalls counts SetPacketRecvTx attempts per sequence. + recvTxCalls map[uint64]int +} + +func newRecordingStore(inner engine.Store) *recordingStore { + return &recordingStore{ + Store: inner, + statusFail: make(map[store.RelayStatus]int), + recvTxFail: make(map[uint64]int), + recvTxCalls: make(map[uint64]int), + } +} + +func (r *recordingStore) failStatus(status store.RelayStatus, times int) { + r.mu.Lock() + defer r.mu.Unlock() + r.statusFail[status] = times +} + +func (r *recordingStore) failRecvTx(seq uint64, times int) { + r.mu.Lock() + defer r.mu.Unlock() + r.recvTxFail[seq] = times +} + +func (r *recordingStore) recvTxAttempts(seq uint64) int { + r.mu.Lock() + defer r.mu.Unlock() + + return r.recvTxCalls[seq] +} + +func (r *recordingStore) UpdatePacketStatus( + ctx context.Context, + key store.PacketKey, + status store.RelayStatus, +) error { + r.mu.Lock() + if n := r.statusFail[status]; n > 0 { + r.statusFail[status] = n - 1 + r.mu.Unlock() + + return assert.AnError + } + r.statuses = append(r.statuses, status) + r.mu.Unlock() + + return r.Store.UpdatePacketStatus(ctx, key, status) +} + +// interceptRecvTx records a SetPacketRecvTx attempt for seq and returns the +// injected failure (if the failRecvTx knob is armed for seq), otherwise nil. It +// is shared by the direct-store and transaction-bound (recordingRepo) recv-tx +// paths so the counting and fault injection live in one place. +func (r *recordingStore) interceptRecvTx(seq uint64) error { + r.mu.Lock() + defer r.mu.Unlock() + r.recvTxCalls[seq]++ + if n := r.recvTxFail[seq]; n > 0 { + r.recvTxFail[seq] = n - 1 + + return assert.AnError + } + + return nil +} + +func (r *recordingStore) SetPacketRecvTx( + ctx context.Context, + key store.PacketKey, + txHash string, + txTime time.Time, + relayer string, +) error { + if err := r.interceptRecvTx(key.PacketSequenceNumber); err != nil { + return err + } + + return r.Store.SetPacketRecvTx(ctx, key, txHash, txTime, relayer) +} + +// ExecTx wraps the transaction's Repository so tx-submission creates made inside +// the atomic create+link block (recordSubmission) are still recorded for +// assertions, while persistence delegates to the real transactional store. +func (r *recordingStore) ExecTx(ctx context.Context, fn func(store.Repository) error) error { + return r.Store.ExecTx(ctx, func(repo store.Repository) error { + return fn(&recordingRepo{Repository: repo, rec: r}) + }) +} + +// recordingRepo records CreateTxSubmission calls onto the parent recordingStore +// while delegating every operation to the transaction-bound Repository. +type recordingRepo struct { + store.Repository + rec *recordingStore +} + +func (rr *recordingRepo) CreateTxSubmission(ctx context.Context, input store.CreateTxSubmission) (int64, error) { + rr.rec.mu.Lock() + rr.rec.submissions = append(rr.rec.submissions, input) + rr.rec.mu.Unlock() + + return rr.Repository.CreateTxSubmission(ctx, input) +} + +// SetPacketRecvTx records the attempt and honours the injected failRecvTx knob +// on the transactional deliver path (recordSubmission persists every packet's tx +// hash through the tx-bound repository). A returned error rolls the whole +// post-broadcast transaction back. +func (rr *recordingRepo) SetPacketRecvTx( + ctx context.Context, + key store.PacketKey, + txHash string, + txTime time.Time, + relayer string, +) error { + if err := rr.rec.interceptRecvTx(key.PacketSequenceNumber); err != nil { + return err + } + + return rr.Repository.SetPacketRecvTx(ctx, key, txHash, txTime, relayer) +} + +func (r *recordingStore) statusLog() []store.RelayStatus { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]store.RelayStatus(nil), r.statuses...) +} + +func (r *recordingStore) submissionsOf(txType store.TxType) []store.CreateTxSubmission { + r.mu.Lock() + defer r.mu.Unlock() + var out []store.CreateTxSubmission + for _, s := range r.submissions { + if s.TxType == txType { + out = append(out, s) + } + } + + return out +} diff --git a/link/internal/relay/engine/statemachine.go b/link/internal/relay/engine/statemachine.go new file mode 100644 index 000000000..91ffc29d4 --- /dev/null +++ b/link/internal/relay/engine/statemachine.go @@ -0,0 +1,1008 @@ +package engine + +import ( + "bytes" + "context" + "encoding/hex" + "math/big" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/pkg/mathx" + "github.com/cosmos/ibc/link/internal/store" +) + +// universalErrorAck is the ack payload emitted when an application callback +// errors; a write ack equal to it is recorded as ERROR. It is +// sha256("UNIVERSAL_ERROR_ACKNOWLEDGEMENT"). +var universalErrorAck = mustHex32("4774d4a575993f963b1c06573736617a457abef8589178db8d10c94b4ab511ab") + +// actionKind is the batched delivery a packet is queued for after advancement. +type actionKind int + +const ( + actionNone actionKind = iota + actionDeliverRecv + actionDeliverTimeout + actionDeliverAck +) + +// packetContext is the per-tick working state for a single packet, combining its +// persisted row with the on-chain packet reconstructed from the source tx. +type packetContext struct { + db store.Packet + packet chains.Packet + eventHeight uint64 + + // ackBytes is populated once the packet reaches ack delivery. + ackBytes [][]byte +} + +func (pc *packetContext) key() store.PacketKey { + return store.PacketKey{ + SourceChainID: pc.db.SourceChainID, + PacketSequenceNumber: pc.db.PacketSequenceNumber, + PacketSourceClientID: pc.db.PacketSourceClientID, + } +} + +// processRoute advances every packet on a route, then submits the batched +// deliveries. Advancement is sequential; delivery is grouped so one client +// update covers many packets. +func (e *Engine) processRoute(ctx context.Context, r *route, packets []store.Packet) { + var recvBatch, ackBatch, timeoutBatch []*packetContext + + for i := range packets { + pc, err := e.loadPacketContext(ctx, r, packets[i]) + if err != nil { + if ctx.Err() != nil { + return + } + e.logger.Warn("loading packet context", + "route", r.key, "sequence", packets[i].PacketSequenceNumber, "err", err) + + continue + } + + switch e.advancePacket(ctx, r, pc) { + case actionDeliverRecv: + recvBatch = append(recvBatch, pc) + case actionDeliverTimeout: + timeoutBatch = append(timeoutBatch, pc) + case actionDeliverAck: + ackBatch = append(ackBatch, pc) + case actionNone: + } + } + + if len(recvBatch) > 0 { + e.deliverRecv(ctx, r, recvBatch) + } + if len(timeoutBatch) > 0 { + e.deliverTimeout(ctx, r, timeoutBatch) + } + if len(ackBatch) > 0 { + e.deliverAck(ctx, r, ackBatch) + } +} + +// loadPacketContext reconstructs the on-chain packet and its send-event height +// from the source tx recorded on the packet row. +func (e *Engine) loadPacketContext(ctx context.Context, r *route, p store.Packet) (*packetContext, error) { + hashBytes, err := hexToBytes(p.SourceTxHash) + if err != nil { + return nil, errors.Wrap(err, "decoding source tx hash") + } + + events, err := r.srcClient.TxPacketEvents(ctx, hashBytes) + if err != nil { + return nil, errors.Wrap(err, "reading source tx events") + } + + for _, ev := range events { + if ev.Kind == chains.KindSendPacket && + ev.Packet.Sequence == p.PacketSequenceNumber && + ev.Packet.SourceClient == p.PacketSourceClientID { + return &packetContext{db: p, packet: ev.Packet, eventHeight: ev.Height}, nil + } + } + + return nil, errors.Errorf("no SendPacket event for sequence %d in tx %s", p.PacketSequenceNumber, p.SourceTxHash) +} + +// advancePacket runs one step of the lifecycle for a single packet, persisting +// status transitions before each phase and returning the batched delivery (if +// any) the packet is now eligible for. +func (e *Engine) advancePacket(ctx context.Context, r *route, pc *packetContext) actionKind { + p := pc.db + + // A delivery tx from a prior tick is in flight: resolve it (retry/advance) + // instead of re-delivering. + switch { + case p.TimeoutTxHash != nil: + return e.resolveTimeoutTx(ctx, r, pc) + case p.AckTxHash != nil: + return e.resolveAckTx(ctx, r, pc) + case p.RecvTxHash != nil: + if !e.resolveRecvTx(ctx, r, pc) { + return actionNone + } + // recv confirmed: fall through to the ack flow. + return e.advanceAck(ctx, r, pc) + } + + // No delivery in flight. Detect out-of-band completion, timeout, or deliver. + return e.advanceFresh(ctx, r, pc) +} + +// advanceFresh handles a packet with no relay tx in flight: it checks for +// out-of-band completion, decides timeout vs recv, and gates on send finality. +func (e *Engine) advanceFresh(ctx context.Context, r *route, pc *packetContext) actionKind { + seq := pc.db.PacketSequenceNumber + + // CHECK_ACK_PACKET_DELIVERY: source commitment gone => acked/timed out + // out-of-band. Backfill a terminal status without submitting anything. + if err := e.setStatus(ctx, pc, store.RelayStatusCheckAckPacketDelivery); err != nil { + return actionNone + } + present, err := e.sourceCommitmentPresent(ctx, r, seq) + if err != nil { + e.logger.Warn("checking source commitment", "route", r.key, "sequence", seq, "err", err) + + return actionNone + } + if !present { + e.backfillSourceGone(ctx, r, pc) + + return actionNone + } + + // CHECK_RECV_PACKET_DELIVERY: receipt already on dest => received + // out-of-band. Backfill recv/write-ack from a dest range scan. + if err = e.setStatus(ctx, pc, store.RelayStatusCheckRecvPacketDelivery); err != nil { + return actionNone + } + received, err := e.receiptPresent(ctx, r, seq) + if err != nil { + e.logger.Warn("checking recv receipt", "route", r.key, "sequence", seq, "err", err) + + return actionNone + } + if received { + e.backfillReceived(ctx, r, pc) + + return actionNone + } + + // Timeout and recv are mutually exclusive: once timed out, never recv. + if e.isTimedOut(ctx, r, pc) { + return e.advanceTimeout(ctx, r, pc) + } + + // AWAITING_SEND_FINALITY: the recv-side attestor set must be able to attest + // the send-event height before a commitment proof can be produced. + if err = e.setStatus(ctx, pc, store.RelayStatusAwaitingSendFinality); err != nil { + return actionNone + } + attestable, err := r.recvProofGen.LatestHeight(ctx) + if err != nil { + e.logger.Warn("recv-side latest height", "route", r.key, "sequence", seq, "err", err) + + return actionNone + } + if attestable < pc.eventHeight { + return actionNone + } + + if pc.db.SourceTxFinalizedTime == nil { + e.setFinalizedTime(ctx, pc, e.store.SetPacketSourceTxFinalizedTime) + } + + // Persistence gates delivery: only queue the recv once the DELIVER status is + // durably recorded, otherwise a crash could re-deliver next tick. + if err := e.setStatus(ctx, pc, store.RelayStatusDeliverRecvPacket); err != nil { + return actionNone + } + + return actionDeliverRecv +} + +// advanceTimeout gates a timed-out packet on the dest chain reaching (and the +// ack-side attestor set attesting) a height whose block time is past the packet +// timeout, then queues a timeout delivery on the source chain. +func (e *Engine) advanceTimeout(ctx context.Context, r *route, pc *packetContext) actionKind { + if err := e.setStatus(ctx, pc, store.RelayStatusAwaitingTimeoutFinality); err != nil { + return actionNone + } + + if _, ok := e.timeoutProofHeight(ctx, r, pc); !ok { + return actionNone + } + + // Persistence gates delivery: don't queue the timeout until DELIVER is stored. + if err := e.setStatus(ctx, pc, store.RelayStatusDeliverTimeoutPacket); err != nil { + return actionNone + } + + return actionDeliverTimeout +} + +// advanceAck drives a recv-confirmed packet through write-ack detection, +// write-ack finality, and ack delivery on the source chain. +func (e *Engine) advanceAck(ctx context.Context, r *route, pc *packetContext) actionKind { + seq := pc.db.PacketSequenceNumber + + // WAIT_FOR_WRITE_ACK: sync acks only — read the write ack emitted in the + // recv tx. + if err := e.setStatus(ctx, pc, store.RelayStatusWaitForWriteAck); err != nil { + return actionNone + } + info, lookup := e.writeAckInfo(ctx, r, pc) + switch lookup { + case writeAckPending: + // Recv tx events not queryable yet: wait and retry next tick. + return actionNone + case writeAckAbsent: + // Confirmed recv carries no write ack for this packet (router no-op): + // clear the recv tx so the packet re-enters fresh evaluation. + e.resetNoOpRecv(ctx, r, pc) + + return actionNone + case writeAckFound: + } + + if pc.db.WriteAckTxHash == nil { + if err := e.store.SetPacketWriteAckTx( + ctx, + pc.key(), + *pc.db.RecvTxHash, + info.blockTime, + info.status, + ); err != nil { + e.logger.Warn("recording write ack", "route", r.key, "sequence", seq, "err", err) + + return actionNone + } + } + + // AWAITING_WRITE_ACK_FINALITY: the ack-side attestor set must attest the + // write-ack height before an acknowledgement proof can be produced. + if err := e.setStatus(ctx, pc, store.RelayStatusAwaitingWriteAckFinality); err != nil { + return actionNone + } + attestable, err := r.ackProofGen.LatestHeight(ctx) + if err != nil { + e.logger.Warn("ack-side latest height", "route", r.key, "sequence", seq, "err", err) + + return actionNone + } + if attestable < info.height { + return actionNone + } + + if pc.db.WriteAckTxFinalizedTime == nil { + e.setFinalizedTime(ctx, pc, e.store.SetPacketWriteAckTxFinalizedTime) + } + + // Guard against acking a packet whose source commitment was cleared out of + // band (avoids a revert/retry loop). Rather than fabricating a completion, + // route through the source-gone backfill so the ACTUAL ack/timeout event is + // discovered and recorded (tx hash + write-ack status), or an explicit + // invariant failure is recorded if no such event exists on chain. + present, err := e.sourceCommitmentPresent(ctx, r, seq) + if err == nil && !present { + e.logger.Info("source commitment cleared before ack; classifying via backfill", + "route", r.key, "sequence", seq) + if err := e.setStatus(ctx, pc, store.RelayStatusCheckAckPacketDelivery); err != nil { + return actionNone + } + e.backfillSourceGone(ctx, r, pc) + + return actionNone + } + + // Persistence gates delivery: don't queue the ack until DELIVER is stored. + if err := e.setStatus(ctx, pc, store.RelayStatusDeliverAckPacket); err != nil { + return actionNone + } + pc.ackBytes = info.acks + + return actionDeliverAck +} + +// resolveRecvTx handles a recv tx already broadcast. It returns true only when +// the recv succeeded on-chain and the packet may advance to the ack flow. +func (e *Engine) resolveRecvTx(ctx context.Context, r *route, pc *packetContext) bool { + return e.resolveInFlight(ctx, resolveParams{ + route: r, + pc: pc, + txHash: *pc.db.RecvTxHash, + txTime: pc.db.RecvTxTime, + submitter: r.dstSubmitter, + clearTx: e.store.ClearPacketRecvTx, + chainID: r.dstChainID, + txType: store.TxTypeRecvPacket, + }) +} + +// resolveAckTx handles an ack tx already broadcast; success is terminal. +func (e *Engine) resolveAckTx(ctx context.Context, r *route, pc *packetContext) actionKind { + if e.resolveInFlight(ctx, resolveParams{ + route: r, + pc: pc, + txHash: *pc.db.AckTxHash, + txTime: pc.db.AckTxTime, + submitter: r.srcSubmitter, + clearTx: e.store.ClearPacketAckTx, + chainID: r.srcChainID, + txType: store.TxTypeAckPacket, + }) { + // Terminal; a failed write is retried next tick (the succeeded ack tx is + // still recorded, so resolveAckTx re-runs). + _ = e.setStatus(ctx, pc, store.RelayStatusCompleteWithAck) + } + + return actionNone +} + +// resolveTimeoutTx handles a timeout tx already broadcast; success is terminal. +func (e *Engine) resolveTimeoutTx(ctx context.Context, r *route, pc *packetContext) actionKind { + if e.resolveInFlight(ctx, resolveParams{ + route: r, + pc: pc, + txHash: *pc.db.TimeoutTxHash, + txTime: pc.db.TimeoutTxTime, + submitter: r.srcSubmitter, + clearTx: e.store.ClearPacketTimeoutTx, + chainID: r.srcChainID, + txType: store.TxTypeTimeoutPacket, + }) { + // Terminal; a failed write is retried next tick. + _ = e.setStatus(ctx, pc, store.RelayStatusCompleteWithTimeout) + } + + return actionNone +} + +type resolveParams struct { + route *route + pc *packetContext + txHash string + txTime *time.Time + submitter Submitter + clearTx func(context.Context, store.PacketKey) error + // chainID is the chain the tx was submitted to; it keys the tx-submission row + // together with txHash when the outcome is resolved. + chainID string + // txType is the relay tx type, used only to label the submission metric. + txType store.TxType +} + +// resolveInFlight applies the reference retry semantics to an in-flight tx, +// driven by a SINGLE receipt read so no chain-state race can record the wrong +// terminal outcome: no receipt -> wait (or clear+re-deliver once expired); +// receipt reverted -> record FAILED and clear; receipt successful -> return +// true. Gas and execution status both come from that one receipt. +func (e *Engine) resolveInFlight(ctx context.Context, rp resolveParams) bool { + hash, err := hexToHash(rp.txHash) + if err != nil { + e.logger.Warn("decoding tx hash", "route", rp.route.key, "hash", rp.txHash, "err", err) + + return false + } + + receipt, err := rp.submitter.Receipt(ctx, hash) + if err != nil { + e.logger.Warn("reading tx receipt", "route", rp.route.key, "hash", rp.txHash, "err", err) + + return false + } + + if receipt == nil { + // Not included yet: re-deliver only once the tx has expired. + sentAt := time.Time{} + if rp.txTime != nil { + sentAt = *rp.txTime + } + + expired, expErr := rp.submitter.Expired(ctx, sentAt, retryExpiry) + if expErr != nil { + // Transient error: wait for the next tick. + e.logger.Warn("checking tx expiry", "route", rp.route.key, "hash", rp.txHash, "err", expErr) + + return false + } + if expired { + // Expired without ever being included: no receipt, no gas. + e.resolveSubmission(ctx, rp, nil, store.TxSubmissionStatusExpired, "") + e.clearTx(ctx, rp) + } + + return false + } + + if receipt.Status != types.ReceiptStatusSuccessful { + // Reverted: a reverted tx still consumes gas, so record it from the same + // receipt. Clear and re-deliver next tick. + e.resolveSubmission(ctx, rp, receipt, store.TxSubmissionStatusFailed, "transaction reverted") + e.clearTx(ctx, rp) + + return false + } + + e.resolveSubmission(ctx, rp, receipt, store.TxSubmissionStatusSuccess, "") + + return true +} + +// resolveSubmission records the terminal outcome of the in-flight tx against its +// tx-submission row. Gas cost (GasUsed * EffectiveGasPrice, in wei) is taken from +// the caller's receipt; a nil receipt (EXPIRED, never included) carries no gas. +// The submission and gas metrics are incremented ONLY when the row actually +// transitioned (rows affected > 0), so a batch's shared tx is counted exactly +// once instead of once per packet per tick. Resolution is best-effort: failures +// are logged and never block the packet state machine. +func (e *Engine) resolveSubmission( + ctx context.Context, + rp resolveParams, + receipt *types.Receipt, + status store.TxSubmissionStatus, + executionError string, +) { + var gasCost *string + var gasWei float64 + gasKnown := false + if receipt != nil { + wei := gasCostWei(receipt) + cost := wei.String() + gasCost = &cost + gasWei, _ = new(big.Float).SetInt(wei).Float64() + gasKnown = true + } + + var execErr *string + if executionError != "" { + execErr = &executionError + } + + rows, err := e.store.ResolveTxSubmission(ctx, store.ResolveTxSubmission{ + ChainID: rp.chainID, + TxHash: rp.txHash, + Status: status, + GasCost: gasCost, + ExecutionError: execErr, + ResolvedAt: e.now(), + }) + if err != nil { + e.logger.Warn("resolving tx submission", + "route", rp.route.key, "hash", rp.txHash, "status", status, "err", err) + + return + } + if rows == 0 { + // The row was already resolved (e.g. another packet in the same batch + // resolved the shared tx, or a re-tick re-resolved it): do not double-count. + return + } + + e.metrics.ObserveTxSubmission(rp.chainID, string(rp.txType), string(status), gasWei, gasKnown) +} + +// gasCostWei returns the total gas cost of a receipt in wei (GasUsed * +// EffectiveGasPrice). +func gasCostWei(receipt *types.Receipt) *big.Int { + gasUsed := new(big.Int).SetUint64(receipt.GasUsed) + + price := receipt.EffectiveGasPrice + if price == nil { + price = big.NewInt(0) + } + + return new(big.Int).Mul(gasUsed, price) +} + +func (e *Engine) clearTx(ctx context.Context, rp resolveParams) { + if err := rp.clearTx(ctx, rp.pc.key()); err != nil { + e.logger.Warn("clearing tx", "route", rp.route.key, "hash", rp.txHash, "err", err) + } +} + +// writeAckInfo reads the write acknowledgement emitted in the recv tx. +type writeAckInfoResult struct { + acks [][]byte + height uint64 + blockTime time.Time + status store.WriteAckStatus +} + +// writeAckLookup classifies the outcome of scanning the recv tx for this packet's +// WriteAcknowledgement. It distinguishes a transient "not queryable yet" from a +// definitive "the confirmed tx contains no write ack for this packet", so the two +// can drive different transitions. +type writeAckLookup int + +const ( + // writeAckPending: the recv tx events could not be read (RPC/decoding error); + // wait and retry on a later tick. + writeAckPending writeAckLookup = iota + // writeAckFound: the write ack for this packet was found in the recv tx. + writeAckFound + // writeAckAbsent: the recv tx events were read successfully but contain no + // write ack for this packet. The recv confirmed successful on chain, so the + // router no-op'd this packet (its receipt was already set out of band, or it + // timed out while the batch accumulated) and the write ack will never appear + // in this tx. + writeAckAbsent +) + +func (e *Engine) writeAckInfo(ctx context.Context, r *route, pc *packetContext) (writeAckInfoResult, writeAckLookup) { + // Reached only from advanceAck, which advancePacket enters solely for a packet + // with a recv tx in flight (RecvTxHash != nil), so the deref below is safe. + hashBytes, err := hexToBytes(*pc.db.RecvTxHash) + if err != nil { + e.logger.Warn("decoding recv tx hash", "route", r.key, "err", err) + + return writeAckInfoResult{}, writeAckPending + } + + events, err := r.dstClient.TxPacketEvents(ctx, hashBytes) + if err != nil { + e.logger.Warn("reading recv tx events", "route", r.key, "err", err) + + return writeAckInfoResult{}, writeAckPending + } + + seq := pc.db.PacketSequenceNumber + for _, ev := range events { + if ev.Kind == chains.KindWriteAck && + ev.Packet.Sequence == seq && + ev.Packet.SourceClient == r.srcClientID && + ev.Packet.DestClient == r.dstClientID { + return writeAckInfoResult{ + acks: ev.Acks, + height: ev.Height, + blockTime: ev.BlockTime, + status: writeAckStatus(ev.Acks), + }, writeAckFound + } + } + + // The recv tx was queried successfully but carries no write ack for this + // packet: the recv no-op'd on chain. Report absent so the caller can clear the + // recv tx rather than stranding the packet in WAIT_FOR_WRITE_ACK forever. + return writeAckInfoResult{}, writeAckAbsent +} + +// resetNoOpRecv handles a recv tx that confirmed successful yet contains no +// WriteAcknowledgement for this packet (the ICS26 router no-op'd it because its +// receipt was already set out of band, or it timed out while the batch +// accumulated). It clears the recv tx and resets the packet to PENDING so the +// next tick re-enters fresh recv/timeout evaluation instead of stranding in +// WAIT_FOR_WRITE_ACK. +func (e *Engine) resetNoOpRecv(ctx context.Context, r *route, pc *packetContext) { + seq := pc.db.PacketSequenceNumber + + if err := e.store.ClearPacketRecvTx(ctx, pc.key()); err != nil { + e.logger.Warn("clearing no-op recv tx", "route", r.key, "sequence", seq, "err", err) + + return + } + pc.db.RecvTxHash = nil + + e.logger.Info("recv tx contained no write ack for packet; cleared for re-evaluation", + "route", r.key, "sequence", seq, "recvTxAbsent", true) + + _ = e.setStatus(ctx, pc, store.RelayStatusPending) +} + +// backfillSourceGone terminalizes a packet whose source commitment is already +// cleared (acked or timed out out-of-band). It range-scans the source chain for +// the AckPacket / TimeoutPacket event for this packet and records the ACTUAL +// event type and tx hash. If the paged scan is exhausted without a match the +// cleared commitment is unexplained, so it records an explicit invariant failure +// rather than fabricating a completion. +func (e *Engine) backfillSourceGone(ctx context.Context, r *route, pc *packetContext) { + seq := pc.db.PacketSequenceNumber + + latest, err := r.srcClient.LatestHeight(ctx) + if err != nil { + e.logger.Warn("source latest height for completion scan", "route", r.key, "sequence", seq, "err", err) + + return + } + + scan := func(ctx context.Context, from, to uint64) ([]chains.PacketEvent, error) { + acks, ackErr := r.srcClient.AckPacketEvents(ctx, from, to) + if ackErr != nil { + return nil, ackErr + } + timeouts, toErr := r.srcClient.TimeoutPacketEvents(ctx, from, to) + if toErr != nil { + return nil, toErr + } + + return append(acks, timeouts...), nil + } + match := func(ev chains.PacketEvent) bool { + return (ev.Kind == chains.KindAckPacket || ev.Kind == chains.KindTimeoutPacket) && + ev.Packet.Sequence == seq && ev.Packet.SourceClient == r.srcClientID + } + + ev, found, exhausted, err := e.pagedBackwardScan(ctx, sourceGoneScanKey(r, seq), latest, scan, match) + if err != nil { + e.logger.Warn("scanning source completion events", "route", r.key, "sequence", seq, "err", err) + + return + } + + if found { + e.terminalizeSourceGone(ctx, pc, ev) + + return + } + + // Not found yet: keep paging on subsequent ticks until exhausted. + if !exhausted { + return + } + + // Exhausted the whole source range without any ack or timeout event: the + // cleared commitment is unexplained by on-chain evidence. This violates the + // invariant that a missing source commitment implies an ack or timeout, so + // record an explicit invariant failure with an operator-visible reason instead + // of fabricating a complete-with-ack that never happened. + e.logger.Error("source commitment cleared but no ack or timeout event found on chain", + "sourceChainID", pc.db.SourceChainID, "sequence", seq) + _ = e.setStatus(ctx, pc, store.RelayStatusFailedInvariant) +} + +// terminalizeSourceGone records the discovered out-of-band completion: for an +// AckPacket it stores the ack tx and completes with ack; for a TimeoutPacket it +// stores the timeout tx and completes with timeout. +func (e *Engine) terminalizeSourceGone(ctx context.Context, pc *packetContext, ev chains.PacketEvent) { + seq := pc.db.PacketSequenceNumber + + if ev.Kind == chains.KindTimeoutPacket { + if err := e.store.SetPacketTimeoutTx(ctx, pc.key(), ev.TxHash, ev.BlockTime, externalRelayer); err != nil { + e.logger.Warn("backfilling out-of-band timeout tx", "sequence", seq, "err", err) + + return + } + e.logger.Info("packet timed out out-of-band; completing", + "sourceChainID", pc.db.SourceChainID, "sequence", seq, "txHash", ev.TxHash) + _ = e.setStatus(ctx, pc, store.RelayStatusCompleteWithTimeout) + + return + } + + if err := e.store.SetPacketAckTx(ctx, pc.key(), ev.TxHash, ev.BlockTime, externalRelayer); err != nil { + e.logger.Warn("backfilling out-of-band ack tx", "sequence", seq, "err", err) + + return + } + + // Classify the observed acknowledgement exactly like the in-band path: an ack + // equal to the universal error ack is recorded as ERROR so the unified status + // derivation reports error_ack rather than an ordinary completion. Record the + // status only: ev is the SOURCE AckPacket event, so the true dest + // writeAcknowledgement tx is unknown on this path and its columns stay NULL. + if err := e.store.SetPacketWriteAckStatus(ctx, pc.key(), writeAckStatus(ev.Acks)); err != nil { + e.logger.Warn("backfilling out-of-band write ack status", "sequence", seq, "err", err) + + return + } + + e.logger.Info("packet acked out-of-band; completing", + "sourceChainID", pc.db.SourceChainID, "sequence", seq, "txHash", ev.TxHash, + "writeAckStatus", writeAckStatus(ev.Acks)) + _ = e.setStatus(ctx, pc, store.RelayStatusCompleteWithAck) +} + +func sourceGoneScanKey(r *route, seq uint64) string { + return "srcgone:" + r.key + ":" + strconv.FormatUint(seq, 10) +} + +func writeAckScanKey(r *route, seq uint64) string { + return "wack:" + r.key + ":" + strconv.FormatUint(seq, 10) +} + +// backfillReceived records recv + write-ack state for a packet received out of +// band, using a dest range scan. It leaves the packet for the next tick to +// carry through the ack flow. +func (e *Engine) backfillReceived(ctx context.Context, r *route, pc *packetContext) { + ev, ok := e.scanWriteAck(ctx, r, pc.db.PacketSequenceNumber) + if !ok { + e.logger.Warn("receipt present but write ack not found in scan; waiting", + "route", r.key, "sequence", pc.db.PacketSequenceNumber) + + return + } + + // The WriteAcknowledgement event is emitted in the recv tx, so its tx hash + // is the recv tx we back-fill. + if err := e.store.SetPacketRecvTx(ctx, pc.key(), ev.TxHash, ev.BlockTime, externalRelayer); err != nil { + e.logger.Warn("backfilling recv tx", "route", r.key, "err", err) + + return + } + if err := e.store.SetPacketWriteAckTx(ctx, pc.key(), ev.TxHash, ev.BlockTime, writeAckStatus(ev.Acks)); err != nil { + e.logger.Warn("backfilling write ack tx", "route", r.key, "err", err) + } +} + +// scanWriteAck scans the dest chain backward, in bounded pages resumed across +// ticks, for the write ack of a packet received out-of-band. Older external +// receives are eventually found instead of being retried against only the most +// recent blocks forever. +func (e *Engine) scanWriteAck(ctx context.Context, r *route, seq uint64) (chains.PacketEvent, bool) { + latest, err := r.dstClient.LatestHeight(ctx) + if err != nil { + e.logger.Warn("dest latest height for scan", "route", r.key, "err", err) + + return chains.PacketEvent{}, false + } + + match := func(ev chains.PacketEvent) bool { + return ev.Kind == chains.KindWriteAck && + ev.Packet.Sequence == seq && + ev.Packet.SourceClient == r.srcClientID && + ev.Packet.DestClient == r.dstClientID + } + + ev, found, _, err := e.pagedBackwardScan(ctx, writeAckScanKey(r, seq), latest, r.dstClient.WriteAckEvents, match) + if err != nil { + e.logger.Warn("scanning write ack events", "route", r.key, "err", err) + + return chains.PacketEvent{}, false + } + + return ev, found +} + +// pagedScanState resumes a backward event scan across ticks. next is the highest +// block not yet scanned; done marks the whole [0, latest] range as scanned. +type pagedScanState struct { + next uint64 + done bool +} + +// pagedBackwardScan scans [0, latest] backward in scanPageSize pages, at most +// scanPagesPerTick pages per call, resuming from the per-key progress marker on +// the next tick. It returns the first event accepted by match (found), and +// exhausted=true once the entire range has been scanned without a match. +func (e *Engine) pagedBackwardScan( + ctx context.Context, + key string, + latest uint64, + scan func(ctx context.Context, from, to uint64) ([]chains.PacketEvent, error), + match func(chains.PacketEvent) bool, +) (event chains.PacketEvent, found, exhausted bool, err error) { + st := e.scanStateFor(key, latest) + if st.done { + return chains.PacketEvent{}, false, true, nil + } + + top := st.next + if top > latest { + top = latest + } + + for pages := 0; pages < scanPagesPerTick; pages++ { + from := mathx.SaturatingSubU64(top, scanPageSize-1) + + events, scanErr := scan(ctx, from, top) + if scanErr != nil { + st.next = top // resume this page next tick + + return chains.PacketEvent{}, false, false, scanErr + } + + for _, ev := range events { + if match(ev) { + e.clearScanState(key) + + return ev, true, false, nil + } + } + + if from == 0 { + st.done = true + + return chains.PacketEvent{}, false, true, nil + } + + top = from - 1 + } + + st.next = top // resume below here next tick + + return chains.PacketEvent{}, false, false, nil +} + +func (e *Engine) scanStateFor(key string, latest uint64) *pagedScanState { + e.scanMu.Lock() + defer e.scanMu.Unlock() + + st, ok := e.scanState[key] + if !ok { + st = &pagedScanState{next: latest} + e.scanState[key] = st + } + + return st +} + +func (e *Engine) clearScanState(key string) { + e.scanMu.Lock() + defer e.scanMu.Unlock() + delete(e.scanState, key) +} + +// receiptPresent reports whether a packet receipt exists on the dest chain. +func (e *Engine) receiptPresent(ctx context.Context, r *route, seq uint64) (bool, error) { + height, err := r.dstClient.LatestHeight(ctx) + if err != nil { + return false, errors.Wrap(err, "dest latest height") + } + + path := attestation.PathHash(attestation.PacketReceiptPath(r.dstClientID, seq)) + commitment, err := r.dstClient.GetCommitment(ctx, path, height) + if err != nil { + return false, errors.Wrap(err, "reading receipt commitment") + } + + return commitment != [32]byte{}, nil +} + +// sourceCommitmentPresent reports whether the packet commitment still exists on +// the source chain (cleared once acked or timed out). +func (e *Engine) sourceCommitmentPresent(ctx context.Context, r *route, seq uint64) (bool, error) { + height, err := r.srcClient.LatestHeight(ctx) + if err != nil { + return false, errors.Wrap(err, "source latest height") + } + + path := attestation.PathHash(attestation.PacketCommitmentPath(r.srcClientID, seq)) + commitment, err := r.srcClient.GetCommitment(ctx, path, height) + if err != nil { + return false, errors.Wrap(err, "reading source commitment") + } + + return commitment != [32]byte{}, nil +} + +// isTimedOut reports whether the dest chain's latest attested block time is at +// or past the packet timeout. +func (e *Engine) isTimedOut(ctx context.Context, r *route, pc *packetContext) bool { + if pc.db.PacketTimeoutTimestamp.IsZero() { + return false + } + + height, err := r.ackProofGen.LatestHeight(ctx) + if err != nil { + return false + } + + ts, err := r.dstClient.HeaderTimestamp(ctx, height) + if err != nil { + return false + } + + return !ts.Before(pc.db.PacketTimeoutTimestamp) +} + +// timeoutProofHeight returns a dest height that the ack-side attestor set can +// attest and whose block time is past the packet timeout, or false to wait. +func (e *Engine) timeoutProofHeight(ctx context.Context, r *route, pc *packetContext) (uint64, bool) { + height, err := r.ackProofGen.LatestHeight(ctx) + if err != nil { + e.logger.Warn("ack-side latest height for timeout", "route", r.key, "err", err) + + return 0, false + } + + ts, err := r.dstClient.HeaderTimestamp(ctx, height) + if err != nil { + e.logger.Warn("dest header timestamp for timeout", "route", r.key, "height", height, "err", err) + + return 0, false + } + + if ts.Before(pc.db.PacketTimeoutTimestamp) { + return 0, false + } + + return height, true +} + +// setStatus persists a packet's status transition and mirrors it onto the +// in-memory context. It returns an error when the write fails so callers can +// abort the packet's tick step: persistence gates the subsequent phase (a phase +// that proceeded on an unpersisted status could re-submit or double-submit on +// the next tick). +func (e *Engine) setStatus(ctx context.Context, pc *packetContext, status store.RelayStatus) error { + if pc.db.Status == status { + return nil + } + + if err := e.store.UpdatePacketStatus(ctx, pc.key(), status); err != nil { + e.logger.Warn("updating packet status", + "sourceChainID", pc.db.SourceChainID, "sequence", pc.db.PacketSequenceNumber, + "status", status, "err", err) + + return err + } + + pc.db.Status = status + + // Count only terminal transitions; the guard above ensures each terminal + // status is recorded once per packet (a no-op re-set returns early). + if outcome, ok := terminalOutcome(status); ok { + e.metrics.IncRelayerPacket( + ClientKey(pc.db.SourceChainID, pc.db.PacketSourceClientID), outcome, + ) + } + + return nil +} + +// terminalOutcome maps a terminal relay status to its metric outcome label, +// returning ok=false for non-terminal statuses (which are not counted). +func terminalOutcome(status store.RelayStatus) (string, bool) { + switch status { + case store.RelayStatusCompleteWithAck, store.RelayStatusCompleteWithWriteAckSuccess: + return metrics.OutcomeCompletedAck, true + case store.RelayStatusCompleteWithWriteAckError: + return metrics.OutcomeErrorAck, true + case store.RelayStatusCompleteWithTimeout: + return metrics.OutcomeCompletedTimeout, true + case store.RelayStatusFailed, store.RelayStatusFailedInvariant: + return metrics.OutcomeFailed, true + default: + return "", false + } +} + +func (e *Engine) setFinalizedTime( + ctx context.Context, + pc *packetContext, + set func(context.Context, store.PacketKey, time.Time) error, +) { + if err := set(ctx, pc.key(), e.now()); err != nil { + e.logger.Warn("recording finalized time", + "sourceChainID", pc.db.SourceChainID, "sequence", pc.db.PacketSequenceNumber, "err", err) + } +} + +// writeAckStatus maps a write ack payload to a stored status: ERROR iff the sole +// ack equals the universal error ack, otherwise SUCCESS. +func writeAckStatus(acks [][]byte) store.WriteAckStatus { + if len(acks) == 1 && bytes.Equal(acks[0], universalErrorAck[:]) { + return store.WriteAckStatusError + } + + return store.WriteAckStatusSuccess +} + +func hexToBytes(h string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(h, "0x")) +} + +func mustHex32(h string) [32]byte { + raw, err := hex.DecodeString(h) + if err != nil || len(raw) != 32 { + panic("engine: invalid 32-byte hex constant") + } + + var out [32]byte + copy(out[:], raw) + + return out +} diff --git a/link/internal/relay/gasmonitor/monitor.go b/link/internal/relay/gasmonitor/monitor.go new file mode 100644 index 000000000..899353a6d --- /dev/null +++ b/link/internal/relay/gasmonitor/monitor.go @@ -0,0 +1,180 @@ +// Package gasmonitor periodically reads the relayer signer's native-token +// balance on each relayed chain, surfaces it as a metric, and raises a low-gas +// alert when the balance falls to a configured threshold. A relayer that runs out +// of gas silently stops delivering packets; this monitor is the signal for that. +package gasmonitor + +import ( + "context" + "log/slog" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cosmos/ibc/link/internal/metrics" +) + +// defaultInterval is the balance poll interval. Gas balances drift slowly, so a +// coarse interval keeps RPC load negligible while still surfacing a drained +// signer within a minute. +const defaultInterval = 45 * time.Second + +type severity int + +// Alert severities, matching the low_gas_alert metric encoding. +const ( + severityOK severity = 0 + severityWarning severity = 1 + severityCritical severity = 2 +) + +// BalanceSource reads an account's wei balance. *evm.Client satisfies it; tests +// supply a fake. +type BalanceSource interface { + BalanceAt(ctx context.Context, account common.Address) (*big.Int, error) +} + +// Target is one chain the monitor polls: the signer Address whose balance is read +// via Source. Warning and Critical are the wei alert thresholds +// (Critical <= Warning). Both nil means the balance is still reported but no +// alert is evaluated for the chain. +type Target struct { + ChainID string + Address common.Address + Source BalanceSource + Warning *big.Int + Critical *big.Int +} + +// alerting reports whether the target has alert thresholds configured. +func (t Target) alerting() bool { + return t.Warning != nil && t.Critical != nil +} + +// evaluate maps a balance to a severity. Both thresholds must be non-nil +// (callers guard with alerting), with critical <= warning enforced by config +// validation. +func evaluate(balance, warning, critical *big.Int) severity { + switch { + case balance.Cmp(critical) <= 0: + return severityCritical + case balance.Cmp(warning) <= 0: + return severityWarning + default: + return severityOK + } +} + +// Monitor polls signer balances across chains and reports the balance and +// low-gas alert metrics. +type Monitor struct { + targets []Target + metrics *metrics.Metrics + logger *slog.Logger + + // lastSeverity tracks the last reported alert level per chain so transitions + // (breach and recovery) are logged once rather than on every tick. + lastSeverity map[string]severity +} + +// New builds a Monitor over the given targets. +func New(targets []Target, m *metrics.Metrics, logger *slog.Logger) *Monitor { + if logger == nil { + logger = slog.Default() + } + logger = logger.With("component", "relay/gasmonitor") + + return &Monitor{ + targets: targets, + metrics: m, + logger: logger, + lastSeverity: make(map[string]severity, len(targets)), + } +} + +// Run polls on every tick until ctx is canceled. The first poll fires +// immediately. +func (m *Monitor) Run(ctx context.Context) { + ticker := time.NewTicker(defaultInterval) + defer ticker.Stop() + + for { + m.runOnce(ctx) + + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (m *Monitor) runOnce(ctx context.Context) { + for _, t := range m.targets { + if ctx.Err() != nil { + return + } + m.poll(ctx, t) + } +} + +// poll reads one target's balance and updates its metrics. A read failure is +// logged and skipped so a single unreachable chain never stalls the others or +// aborts the loop. +func (m *Monitor) poll(ctx context.Context, t Target) { + balance, err := t.Source.BalanceAt(ctx, t.Address) + if err != nil { + m.logger.Warn("reading signer balance", "chainID", t.ChainID, "address", t.Address, "err", err) + + return + } + + m.metrics.SetRelayerSignerBalanceWei(t.ChainID, weiToFloat(balance)) + + if !t.alerting() { + return + } + + level := evaluate(balance, t.Warning, t.Critical) + m.metrics.SetRelayerLowGasAlert(t.ChainID, float64(level)) + m.logTransition(t, level, balance) +} + +// logTransition logs a breach or recovery only when the severity changed since +// the last poll of this chain. +func (m *Monitor) logTransition(t Target, severity severity, balance *big.Int) { + prev, seen := m.lastSeverity[t.ChainID] + m.lastSeverity[t.ChainID] = severity + + if seen && prev == severity { + return + } + + switch severity { + case severityCritical: + m.logger.Error("relayer signer balance critically low", + "chainID", t.ChainID, "address", t.Address, + "balanceWei", balance.String(), "criticalThreshold", t.Critical.String()) + case severityWarning: + m.logger.Warn("relayer signer balance low", + "chainID", t.ChainID, "address", t.Address, + "balanceWei", balance.String(), "warningThreshold", t.Warning.String()) + case severityOK: + // Only report recovery when a prior alert was raised; a first-ever OK poll + // is unremarkable and stays out of the log. + if seen { + m.logger.Info("relayer signer balance recovered", + "chainID", t.ChainID, "address", t.Address, "balanceWei", balance.String()) + } + } +} + +// weiToFloat converts a wei balance to the float64 the gauge stores. Balances +// above 2^53 wei lose exact-integer precision, which is immaterial for a balance +// gauge and threshold alerting. +func weiToFloat(wei *big.Int) float64 { + f, _ := new(big.Float).SetInt(wei).Float64() + + return f +} diff --git a/link/internal/relay/gasmonitor/monitor_test.go b/link/internal/relay/gasmonitor/monitor_test.go new file mode 100644 index 000000000..44887df20 --- /dev/null +++ b/link/internal/relay/gasmonitor/monitor_test.go @@ -0,0 +1,175 @@ +package gasmonitor + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/metrics" +) + +type fakeBalanceSource struct { + balances []*big.Int + err error + calls int + onCall func() +} + +func (f *fakeBalanceSource) BalanceAt(_ context.Context, _ common.Address) (*big.Int, error) { + f.calls++ + if f.onCall != nil { + f.onCall() + } + if f.err != nil { + return nil, f.err + } + + i := f.calls - 1 + if i >= len(f.balances) { + i = len(f.balances) - 1 + } + + return f.balances[i], nil +} + +func wei(s string) *big.Int { + v, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("bad wei: " + s) + } + + return v +} + +func TestEvaluate(t *testing.T) { + warning := wei("500") + critical := wei("100") + + for _, tt := range []struct { + name string + balance *big.Int + want severity + }{ + {"above warning", wei("501"), severityOK}, + {"at warning", wei("500"), severityWarning}, + {"between", wei("101"), severityWarning}, + {"at critical", wei("100"), severityCritical}, + {"below critical", wei("1"), severityCritical}, + {"zero", wei("0"), severityCritical}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, evaluate(tt.balance, warning, critical)) + }) + } +} + +func TestRunOnceReportsBalanceAndAlert(t *testing.T) { + t.Run("critical balance sets both gauges", func(t *testing.T) { + m := metrics.New() + source := &fakeBalanceSource{balances: []*big.Int{wei("50")}} + monitor := New([]Target{{ + ChainID: "1", + Address: common.HexToAddress("0x1"), + Source: source, + Warning: wei("500"), + Critical: wei("100"), + }}, m, nil) + + monitor.runOnce(context.Background()) + + assert.Equal(t, 50.0, gauge(t, m, "ibclink_relayer_signer_balance_wei")) + assert.Equal(t, float64(severityCritical), gauge(t, m, "ibclink_relayer_low_gas_alert")) + }) + + t.Run("healthy balance reports ok", func(t *testing.T) { + m := metrics.New() + source := &fakeBalanceSource{balances: []*big.Int{wei("1000")}} + monitor := New([]Target{{ + ChainID: "1", + Source: source, + Warning: wei("500"), + Critical: wei("100"), + }}, m, nil) + + monitor.runOnce(context.Background()) + + assert.Equal(t, 1000.0, gauge(t, m, "ibclink_relayer_signer_balance_wei")) + assert.Equal(t, float64(severityOK), gauge(t, m, "ibclink_relayer_low_gas_alert")) + }) + + t.Run("no thresholds tracks balance without alert gauge", func(t *testing.T) { + m := metrics.New() + source := &fakeBalanceSource{balances: []*big.Int{wei("7")}} + monitor := New([]Target{{ChainID: "1", Source: source}}, m, nil) + + monitor.runOnce(context.Background()) + + assert.Equal(t, 7.0, gauge(t, m, "ibclink_relayer_signer_balance_wei")) + assert.Equal(t, 0, testutil.CollectAndCount(m.Registry(), "ibclink_relayer_low_gas_alert")) + }) + + t.Run("read error does not panic or record", func(t *testing.T) { + m := metrics.New() + source := &fakeBalanceSource{err: errors.New("rpc down")} + monitor := New( + []Target{{ChainID: "1", Source: source, Warning: wei("5"), Critical: wei("1")}}, m, nil, + ) + + monitor.runOnce(context.Background()) + + assert.Equal(t, 0, testutil.CollectAndCount(m.Registry(), "ibclink_relayer_signer_balance_wei")) + assert.Equal(t, 1, source.calls) + }) +} + +func TestSeverityRecoversAcrossPolls(t *testing.T) { + m := metrics.New() + source := &fakeBalanceSource{balances: []*big.Int{wei("1"), wei("1000")}} + monitor := New( + []Target{{ChainID: "1", Source: source, Warning: wei("500"), Critical: wei("100")}}, m, nil, + ) + + monitor.runOnce(context.Background()) + assert.Equal(t, float64(severityCritical), gauge(t, m, "ibclink_relayer_low_gas_alert")) + + monitor.runOnce(context.Background()) + + assert.Equal(t, float64(severityOK), gauge(t, m, "ibclink_relayer_low_gas_alert")) + assert.Equal(t, 1000.0, gauge(t, m, "ibclink_relayer_signer_balance_wei")) +} + +func TestRunReturnsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + source := &fakeBalanceSource{balances: []*big.Int{wei("1")}, onCall: cancel} + monitor := New([]Target{{ChainID: "1", Source: source}}, metrics.New(), nil) + + monitor.Run(ctx) + assert.Equal(t, 1, source.calls, "first poll fires immediately, before any tick") +} + +// gauge reads a single-series gauge value from the metrics registry by name. +func gauge(t *testing.T, m *metrics.Metrics, name string) float64 { + t.Helper() + + families, err := m.Registry().Gather() + require.NoError(t, err) + + for _, family := range families { + if family.GetName() != name { + continue + } + require.Len(t, family.GetMetric(), 1) + + return family.GetMetric()[0].GetGauge().GetValue() + } + + t.Fatalf("metric %q not found", name) + + return 0 +} diff --git a/link/internal/relay/proofgen/aggregator.go b/link/internal/relay/proofgen/aggregator.go new file mode 100644 index 000000000..86244506b --- /dev/null +++ b/link/internal/relay/proofgen/aggregator.go @@ -0,0 +1,348 @@ +package proofgen + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/service/attestor" +) + +// signatureLen is the exact length of a recoverable secp256k1 signature the +// on-chain light client accepts; responses with any other length are dropped. +const signatureLen = 65 + +// defaultTimeout bounds a single attestor RPC when no timeout is configured. +const defaultTimeout = 15 * time.Second + +// Aggregated is a quorum of attestations that agree on the attested data, +// carrying the signatures collected in configured client order. +type Aggregated struct { + Height uint64 + AttestedData []byte + Signatures [][]byte +} + +// Aggregator fans attestation requests out to an ordered set of attestor members +// and reduces the responses to a single quorum-backed result. +type Aggregator struct { + members []AttestorMember + threshold int + timeout time.Duration + logger *slog.Logger +} + +// NewAggregator validates the set and returns an Aggregator. threshold must be +// in [1, len(members)]; a non-positive timeout falls back to the default. +// Expected addresses must be unique across the set: a duplicate would make +// threshold counting unsound (two members authorizing the same on-chain signer, +// so a single attestor could be counted twice toward quorum). +func NewAggregator(members []AttestorMember, threshold int, timeout time.Duration) (*Aggregator, error) { + switch { + case len(members) == 0: + return nil, errors.New("aggregator requires at least one attestor client") + case threshold < 1: + return nil, errors.Errorf("threshold must be at least 1, got %d", threshold) + case threshold > len(members): + return nil, errors.Errorf("threshold %d exceeds number of attestors %d", threshold, len(members)) + } + + seenAddr := make(map[common.Address]bool, len(members)) + for _, m := range members { + if m.Client == nil { + return nil, errors.New("attestor member has a nil client") + } + if (m.Expected == common.Address{}) { + return nil, errors.Errorf("attestor %q has a zero expected address", m.Client.Alias()) + } + if seenAddr[m.Expected] { + return nil, errors.Errorf( + "duplicate expected attestor address %s (aliased by %q)", + m.Expected, + m.Client.Alias(), + ) + } + seenAddr[m.Expected] = true + } + + if timeout <= 0 { + timeout = defaultTimeout + } + + return &Aggregator{ + members: members, + threshold: threshold, + timeout: timeout, + logger: slog.With("component", "proofgen/aggregator"), + }, nil +} + +// LatestHeight fans out LatestAttestableHeight, drops failures, and returns the +// highest height H for which at least threshold responders report a height >= H +// (the threshold-th largest reported height). This guarantees a quorum can serve +// any height <= H, so a lagging attestor cannot pin the height down while a +// quorum still leads; fewer responders than the threshold is an error. +func (a *Aggregator) LatestHeight(ctx context.Context) (uint64, error) { + heights := make([]uint64, len(a.members)) + errs := make([]error, len(a.members)) + + var wg sync.WaitGroup + for i, m := range a.members { + wg.Add(1) + go func() { + defer wg.Done() + callCtx, cancel := context.WithTimeout(ctx, a.timeout) + defer cancel() + heights[i], errs[i] = m.Client.LatestAttestableHeight(callCtx) + }() + } + wg.Wait() + + responded := make([]uint64, 0, len(a.members)) + for i, m := range a.members { + if errs[i] != nil { + a.logger.Warn("attestor latest height failed", "attestor", m.Client.Alias(), "err", errs[i]) + continue + } + responded = append(responded, heights[i]) + } + + if len(responded) < a.threshold { + return 0, errors.Errorf("insufficient responders for latest height: %d responded, need %d", + len(responded), a.threshold) + } + + sort.Slice(responded, func(i, j int) bool { return responded[i] > responded[j] }) + + return responded[a.threshold-1], nil +} + +// StateAttestation fans out a state attestation at height and reduces to quorum. +func (a *Aggregator) StateAttestation(ctx context.Context, height uint64) (*Aggregated, error) { + results := a.fanOut(ctx, func(callCtx context.Context, client AttestorClient) (*attestor.Attestation, error) { + return client.StateAttestation(callCtx, height) + }) + return a.reduce(results, attKind{ + tag: attestation.TypeState, + reqHeight: height, + decodeHeight: func(data []byte) (uint64, error) { + h, _, err := attestation.DecodeStateAttestation(data) + return h, err + }, + }) +} + +// PacketAttestation fans out a multi-packet attestation at height and reduces to quorum. +func (a *Aggregator) PacketAttestation( + ctx context.Context, + packets [][]byte, + height uint64, + proofType attestor.ProofType, +) (*Aggregated, error) { + results := a.fanOut(ctx, func(callCtx context.Context, client AttestorClient) (*attestor.Attestation, error) { + return client.PacketAttestation(callCtx, packets, height, proofType) + }) + return a.reduce(results, attKind{ + tag: attestation.TypePacket, + reqHeight: height, + decodeHeight: func(data []byte) (uint64, error) { + h, _, err := attestation.DecodePacketAttestation(data) + return h, err + }, + }) +} + +// attKind carries everything reduce needs to validate a group of attestations of a +// single type against what the on-chain client will accept: the domain-separation +// tag used to rebuild the signing digest, the request height that the *signed* +// height must equal, and the decoder that extracts the signed height from attested +// data. The on-chain client always derives the proof height from the signed data +// (StateAttestation.height / PacketAttestation.height) and rejects any mismatch +// (HeightMismatch / InvalidState), so we never trust an attestor's unsigned Height +// metadata field. +type attKind struct { + tag byte + reqHeight uint64 + decodeHeight func(data []byte) (uint64, error) +} + +// clientResult is a single attestor's response in configured client order, +// carrying the expected signer address so reduce can reject a response whose +// recovered signer is not the one this member is authorized for. +type clientResult struct { + alias string + expected common.Address + att *attestor.Attestation + err error +} + +// fanOut calls every client in parallel, each bounded by the per-call timeout. +// Results are index-aligned with a.members (configured order preserved). +func (a *Aggregator) fanOut( + ctx context.Context, + call func(ctx context.Context, client AttestorClient) (*attestor.Attestation, error), +) []clientResult { + results := make([]clientResult, len(a.members)) + + var wg sync.WaitGroup + for i, m := range a.members { + wg.Add(1) + go func() { + defer wg.Done() + callCtx, cancel := context.WithTimeout(ctx, a.timeout) + defer cancel() + att, err := call(callCtx, m.Client) + results[i] = clientResult{alias: m.Client.Alias(), expected: m.Expected, att: att, err: err} + }() + } + wg.Wait() + + return results +} + +// group accumulates the verified signatures of responses that share attested +// data, kept in configured client order. +type group struct { + data []byte + sigs [][]byte +} + +// reduce groups responses by attested-data byte-equality and returns the first +// group (in configured order) to reach the quorum threshold, assembling a proof the +// on-chain attestation client will accept. Before a signature can join a group it +// must survive every check the client applies in updateClient / verifyMembership: +// +// - the response must carry a 65-byte signature (InvalidSignatureLength on chain); +// - the signed attested data must decode and encode the request height, because +// the client derives the proof height from the signed data and reverts on any +// mismatch (HeightMismatch / InvalidState) — never from the unsigned metadata; +// - the signature must recover under sha256(tag||sha256(data)) (SignatureInvalid); +// - the recovered signer must equal the address this member is authorized for, +// i.e. the address the light client was deployed with (UnknownSigner on chain). +// +// The on-chain client also reverts on DuplicateSigner, but a group can never carry +// two signatures from the same signer: each member contributes exactly one response +// and every accepted signature must recover to that member's expected address, and +// expected addresses are unique across the set (enforced at construction). +// +// The expected-signer check is what closes the wrong-key liveness hole: without it, +// a mis-keyed endpoint returning a valid-shape signature over the agreed data would +// be counted toward quorum and, if ordered within the first threshold positions, +// submitted — reverting on-chain with UnknownSigner and retrying indefinitely while +// a healthy quorum sat available. Now such a response is dropped as wrong-signer and +// never occupies a slot. +// +// A group that reaches the threshold is truncated to exactly the threshold count in +// configured order: the client accepts >= threshold signatures, but submitting the +// fewest it accepts minimizes calldata/gas and keeps the proof to the members that +// actually reached quorum first. +func (a *Aggregator) reduce(results []clientResult, kind attKind) (*Aggregated, error) { + order := make([]string, 0, len(results)) + groups := make(map[string]*group, len(results)) + outcomes := make([]string, 0, len(results)) + + for _, r := range results { + switch { + case r.err != nil: + a.logger.Warn("attestor request failed", "attestor", r.alias, "err", r.err) + outcomes = append(outcomes, fmt.Sprintf("%s=error(%v)", r.alias, r.err)) + continue + case r.att == nil: + a.logger.Warn("attestor returned no attestation", "attestor", r.alias) + outcomes = append(outcomes, fmt.Sprintf("%s=nil", r.alias)) + continue + case len(r.att.Signature) != signatureLen: + a.logger.Warn("dropping attestation with invalid signature length", + "attestor", r.alias, "len", len(r.att.Signature)) + outcomes = append(outcomes, fmt.Sprintf("%s=bad-sig-len(%d)", r.alias, len(r.att.Signature))) + continue + } + + // Verify the signed height matches the request. The client decodes the height + // from the signed data and reverts on any mismatch, so an attestor that signs a + // different height (or returns undecodable data) can never be part of a quorum. + signedHeight, err := kind.decodeHeight(r.att.AttestedData) + if err != nil { + a.logger.Warn("dropping attestation with undecodable attested data", + "attestor", r.alias, "err", err) + outcomes = append(outcomes, fmt.Sprintf("%s=bad-data", r.alias)) + continue + } + if signedHeight != kind.reqHeight { + a.logger.Warn("dropping attestation with mismatched signed height", + "attestor", r.alias, "signed", signedHeight, "requested", kind.reqHeight) + outcomes = append(outcomes, + fmt.Sprintf("%s=height-mismatch(signed=%d,want=%d)", r.alias, signedHeight, kind.reqHeight)) + continue + } + + // Recover the signer exactly as the client does and drop any signature that + // would revert there (SignatureInvalid), so one malformed or mis-keyed + // signature cannot sink an otherwise-healthy quorum. + digest := attestation.Digest(kind.tag, r.att.AttestedData) + signer, err := attestation.RecoverSigner(digest, r.att.Signature) + if err != nil { + a.logger.Warn("dropping attestation with unrecoverable signature", + "attestor", r.alias, "err", err) + outcomes = append(outcomes, fmt.Sprintf("%s=bad-sig", r.alias)) + continue + } + + // Reject a response whose recovered signer is not the address this member + // is authorized for. The on-chain client reverts with UnknownSigner on any + // unlisted signer, so a mis-keyed endpoint must never occupy a quorum slot. + if signer != r.expected { + a.logger.Warn("dropping attestation from unexpected signer", + "attestor", r.alias, "got", signer, "want", r.expected) + outcomes = append(outcomes, + fmt.Sprintf("%s=wrong-signer(got=%s, want=%s)", r.alias, signer, r.expected)) + continue + } + + key := string(r.att.AttestedData) + g, ok := groups[key] + if !ok { + g = &group{data: r.att.AttestedData} + groups[key] = g + order = append(order, key) + } + + g.sigs = append(g.sigs, r.att.Signature) + outcomes = append(outcomes, fmt.Sprintf("%s=ok(data=%x)", r.alias, dataPrefix(r.att.AttestedData))) + } + + for _, key := range order { + g := groups[key] + if len(g.sigs) < a.threshold { + continue + } + // Submit exactly the threshold count in configured order; the height comes + // from the request, verified above to equal the signed height. + sigs := make([][]byte, a.threshold) + copy(sigs, g.sigs[:a.threshold]) + return &Aggregated{ + Height: kind.reqHeight, + AttestedData: g.data, + Signatures: sigs, + }, nil + } + + return nil, errors.Errorf("quorum not met: no attested-data group reached threshold %d; outcomes: [%s]", + a.threshold, strings.Join(outcomes, ", ")) +} + +// dataPrefix returns up to the first 8 bytes of attested data for error/log summaries. +func dataPrefix(data []byte) []byte { + if len(data) > 8 { + return data[:8] + } + return data +} diff --git a/link/internal/relay/proofgen/aggregator_test.go b/link/internal/relay/proofgen/aggregator_test.go new file mode 100644 index 000000000..d04901840 --- /dev/null +++ b/link/internal/relay/proofgen/aggregator_test.go @@ -0,0 +1,534 @@ +package proofgen + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/service/attestor" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +// member pairs a fakeClient with the address the aggregator must see recovered +// from its signatures. Most tests derive Expected from the signer that produced +// the attestation via memberFor; drop-before-recovery cases (error/nil/bad-length/ +// height-mismatch/unrecoverable) use uniqAddr since Expected is never consulted, +// yet must still be unique and non-zero to satisfy construction. +func memberFor(t *testing.T, fc *fakeClient, s *signer.LocalSecp256k1Signer) AttestorMember { + t.Helper() + return AttestorMember{Client: fc, Expected: addressOf(t, s)} +} + +// uniqAddr returns a fresh, unique EVM address for members whose Expected value +// is irrelevant to the assertion but must satisfy the unique/non-zero invariant. +func uniqAddr(t *testing.T) common.Address { + t.Helper() + return addressOf(t, newSigner(t)) +} + +// fakeClient is a hand-rolled AttestorClient for aggregator tests. +type fakeClient struct { + alias string + + height uint64 + heightErr error + + // att is returned by both State/PacketAttestation; attErr short-circuits it. + att *attestor.Attestation + attErr error + + // delay simulates a slow attestor; the call respects ctx cancellation. + delay time.Duration + + calls atomic.Int64 +} + +func (f *fakeClient) Alias() string { return f.alias } + +func (f *fakeClient) LatestAttestableHeight(ctx context.Context) (uint64, error) { + if err := f.wait(ctx); err != nil { + return 0, err + } + return f.height, f.heightErr +} + +func (f *fakeClient) StateAttestation(ctx context.Context, _ uint64) (*attestor.Attestation, error) { + return f.attest(ctx) +} + +func (f *fakeClient) PacketAttestation( + ctx context.Context, _ [][]byte, _ uint64, _ attestor.ProofType, +) (*attestor.Attestation, error) { + return f.attest(ctx) +} + +func (f *fakeClient) attest(ctx context.Context) (*attestor.Attestation, error) { + f.calls.Add(1) + if err := f.wait(ctx); err != nil { + return nil, err + } + if f.attErr != nil { + return nil, f.attErr + } + return f.att, nil +} + +// wait blocks for f.delay or until ctx is done, returning ctx.Err on cancellation. +func (f *fakeClient) wait(ctx context.Context) error { + if f.delay <= 0 { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(f.delay): + return nil + } +} + +// stateAtt builds a signed state attestation for (height, ts). metaHeight is stuffed +// into the unsigned Height metadata field so tests can prove reduce ignores it and +// takes the height from the request / signed data instead. +func stateAtt( + t *testing.T, ctx context.Context, s *signer.LocalSecp256k1Signer, metaHeight, height, ts uint64, +) *attestor.Attestation { + t.Helper() + data := attestation.EncodeStateAttestation(height, ts) + return &attestor.Attestation{ + Height: metaHeight, + Timestamp: &ts, + AttestedData: data, + Signature: signState(t, ctx, s, data), + } +} + +// badSigAtt builds a state attestation at (height, ts) whose 65-byte signature is +// unrecoverable (invalid v byte), mimicking a mis-keyed/garbage attestor that still +// returns a well-formed-length signature. +func badSigAtt(t *testing.T, height, ts uint64) *attestor.Attestation { + t.Helper() + return &attestor.Attestation{ + Height: height, + AttestedData: attestation.EncodeStateAttestation(height, ts), + Signature: sig65(0x09), // v=0x09 is not a valid recovery id, recovery fails + } +} + +func newSigner(t *testing.T) *signer.LocalSecp256k1Signer { + t.Helper() + s, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + return s +} + +func sig65(fill byte) []byte { + s := make([]byte, 65) + for i := range s { + s[i] = fill + } + return s +} + +const fastTimeout = 200 * time.Millisecond + +func TestNewAggregator(t *testing.T) { + newMembers := func() []AttestorMember { + return []AttestorMember{ + {Client: &fakeClient{alias: "a"}, Expected: uniqAddr(t)}, + {Client: &fakeClient{alias: "b"}, Expected: uniqAddr(t)}, + } + } + + t.Run("defaultsTimeout", func(t *testing.T) { + agg, err := NewAggregator(newMembers(), 1, 0) + require.NoError(t, err) + assert.Equal(t, defaultTimeout, agg.timeout) + }) + + t.Run("noClients", func(t *testing.T) { + _, err := NewAggregator(nil, 1, fastTimeout) + require.ErrorContains(t, err, "at least one attestor") + }) + + t.Run("thresholdTooLow", func(t *testing.T) { + _, err := NewAggregator(newMembers(), 0, fastTimeout) + require.ErrorContains(t, err, "at least 1") + }) + + t.Run("thresholdTooHigh", func(t *testing.T) { + _, err := NewAggregator(newMembers(), 3, fastTimeout) + require.ErrorContains(t, err, "exceeds number of attestors") + }) + + t.Run("duplicateExpectedAddress", func(t *testing.T) { + // Two members that map to the same on-chain signer make threshold counting + // unsound (a single attestor could be counted twice), so it is rejected up + // front rather than silently double-counted. + shared := uniqAddr(t) + members := []AttestorMember{ + {Client: &fakeClient{alias: "a"}, Expected: shared}, + {Client: &fakeClient{alias: "b"}, Expected: shared}, + } + _, err := NewAggregator(members, 1, fastTimeout) + require.ErrorContains(t, err, "duplicate expected attestor address") + }) + + t.Run("zeroExpectedAddress", func(t *testing.T) { + members := []AttestorMember{ + {Client: &fakeClient{alias: "a"}, Expected: common.Address{}}, + } + _, err := NewAggregator(members, 1, fastTimeout) + require.ErrorContains(t, err, "zero expected address") + }) +} + +func TestAggregatorReduce(t *testing.T) { + ctx := context.Background() + + const ( + height = uint64(50) + ts = uint64(1_700_000_500) + ) + + t.Run("quorumExactlyAtThreshold", func(t *testing.T) { + sa, sb, sc := newSigner(t), newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attB := stateAtt(t, ctx, sb, height, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + memberFor(t, &fakeClient{alias: "b", att: attB}, sb), + memberFor(t, &fakeClient{alias: "c", att: stateAtt(t, ctx, sc, height, height, ts+1)}, sc), // different data + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, height, res.Height) + assert.Equal(t, attA.AttestedData, res.AttestedData) + require.Len(t, res.Signatures, 2) + assert.Equal(t, [][]byte{attA.Signature, attB.Signature}, res.Signatures) + }) + + t.Run("signaturesInConfiguredOrderNotCompletionOrder", func(t *testing.T) { + // "a" is slow but must still appear first in the signature list. + sa, sb, sc := newSigner(t), newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attB := stateAtt(t, ctx, sb, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA, delay: 60 * time.Millisecond}, sa), + memberFor(t, &fakeClient{alias: "b", att: attB}, sb), + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 3, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, [][]byte{attA.Signature, attB.Signature, attC.Signature}, res.Signatures) + }) + + t.Run("laggingAttestorDroppedQuorumStillMet", func(t *testing.T) { + sa, sc := newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + {Client: &fakeClient{alias: "b", attErr: assertErr("boom")}, Expected: uniqAddr(t)}, + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, [][]byte{attA.Signature, attC.Signature}, res.Signatures) + }) + + t.Run("quorumNotMetAllDisagree", func(t *testing.T) { + // Same height, distinct timestamps => distinct signed data => three singleton + // groups, none of which reaches the threshold. + sa, sb, sc := newSigner(t), newSigner(t), newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + memberFor(t, &fakeClient{alias: "b", att: stateAtt(t, ctx, sb, height, height, ts+1)}, sb), + memberFor(t, &fakeClient{alias: "c", att: stateAtt(t, ctx, sc, height, height, ts+2)}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "quorum not met") + require.ErrorContains(t, err, "threshold 2") + require.ErrorContains(t, err, "a=ok") + }) + + t.Run("quorumNotMetTooFewResponders", func(t *testing.T) { + sa := newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + {Client: &fakeClient{alias: "b", attErr: assertErr("down")}, Expected: uniqAddr(t)}, + {Client: &fakeClient{alias: "c", attErr: assertErr("down")}, Expected: uniqAddr(t)}, + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "quorum not met") + require.ErrorContains(t, err, "b=error") + }) + + t.Run("signatureLengthFiltering", func(t *testing.T) { + sa, sc := newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + attB := stateAtt(t, ctx, newSigner(t), height, height, ts) + attB.Signature = []byte{0x01, 0x02} // wrong length, dropped + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + {Client: &fakeClient{alias: "b", att: attB}, Expected: uniqAddr(t)}, + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, [][]byte{attA.Signature, attC.Signature}, res.Signatures) + }) + + t.Run("signatureLengthFilteringDropsBelowQuorum", func(t *testing.T) { + sa := newSigner(t) + attB := stateAtt(t, ctx, newSigner(t), height, height, ts) + attB.Signature = []byte{0x01, 0x02} + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + {Client: &fakeClient{alias: "b", att: attB}, Expected: uniqAddr(t)}, + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "bad-sig-len(2)") + }) + + t.Run("extraHealthyMemberDroppedByTruncation", func(t *testing.T) { + // Three configured members that all agree on the data and recover to their + // own expected addresses. The proof carries only the threshold count in + // configured order; the third member is dropped by truncation, not rejected. + sa, sb, sc := newSigner(t), newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attB := stateAtt(t, ctx, sb, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + memberFor(t, &fakeClient{alias: "b", att: attB}, sb), + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + require.Len(t, res.Signatures, 2) + assert.Equal(t, [][]byte{attA.Signature, attB.Signature}, res.Signatures) + }) + + t.Run("wrongKeyEndpointOrderedFirstQuorumStillMet", func(t *testing.T) { + // A mis-keyed endpoint ordered FIRST returns a valid-shape signature over the + // agreed data, but signs with a key that is not the address it is configured + // (authorized) for. It must be rejected as wrong-signer so the two healthy + // members still form quorum, and the proof must contain exactly their two + // signatures — never the wrong-keyed one, which would revert on-chain with + // UnknownSigner. + wrongKey := newSigner(t) // the key the bad endpoint actually signs with + authorized := newSigner(t) // the address the bad endpoint is configured for + sb, sc := newSigner(t), newSigner(t) + attBad := stateAtt(t, ctx, wrongKey, height, height, ts) // valid shape, valid data, wrong key + attB := stateAtt(t, ctx, sb, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + members := []AttestorMember{ + {Client: &fakeClient{alias: "bad", att: attBad}, Expected: addressOf(t, authorized)}, + memberFor(t, &fakeClient{alias: "b", att: attB}, sb), + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + require.Len(t, res.Signatures, 2) + assert.Equal(t, [][]byte{attB.Signature, attC.Signature}, res.Signatures) + }) + + t.Run("wrongSignerDropsBelowQuorum", func(t *testing.T) { + // Only threshold-1 healthy members remain once the wrong-keyed one is + // rejected, so quorum fails with a wrong-signer outcome for the bad endpoint. + wrongKey := newSigner(t) + authorized := newSigner(t) + sa := newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + {Client: &fakeClient{alias: "bad", att: stateAtt(t, ctx, wrongKey, height, height, ts)}, + Expected: addressOf(t, authorized)}, + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "quorum not met") + require.ErrorContains(t, err, "bad=wrong-signer") + }) + + t.Run("garbageSignatureDroppedQuorumStillMet", func(t *testing.T) { + sa, sc := newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, height, height, ts) + attC := stateAtt(t, ctx, sc, height, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + {Client: &fakeClient{alias: "b", att: badSigAtt(t, height, ts)}, Expected: uniqAddr(t)}, // unrecoverable + memberFor(t, &fakeClient{alias: "c", att: attC}, sc), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, [][]byte{attA.Signature, attC.Signature}, res.Signatures) + }) + + t.Run("garbageSignatureDropsBelowQuorum", func(t *testing.T) { + sa := newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + {Client: &fakeClient{alias: "b", att: badSigAtt(t, height, ts)}, Expected: uniqAddr(t)}, + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "quorum not met") + require.ErrorContains(t, err, "b=bad-sig") + }) + + t.Run("heightFromRequestNotResponseMetadata", func(t *testing.T) { + // Metadata Height is a lie (999) but the signed data encodes the request + // height; the result must reflect the request, never the metadata. + sa, sb := newSigner(t), newSigner(t) + attA := stateAtt(t, ctx, sa, 999, height, ts) + attB := stateAtt(t, ctx, sb, 999, height, ts) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: attA}, sa), + memberFor(t, &fakeClient{alias: "b", att: attB}, sb), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + res, err := agg.StateAttestation(ctx, height) + require.NoError(t, err) + assert.Equal(t, height, res.Height) + }) + + t.Run("signedHeightMismatchDropped", func(t *testing.T) { + // An attestor that signs a different height than requested is dropped: the + // on-chain client would revert with HeightMismatch. + sa, sb := newSigner(t), newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + memberFor(t, &fakeClient{alias: "b", att: stateAtt(t, ctx, sb, height, height+1, ts)}, sb), // signs 51 + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.StateAttestation(ctx, height) + require.ErrorContains(t, err, "quorum not met") + require.ErrorContains(t, err, "b=height-mismatch") + }) + + t.Run("perCallTimeoutEnforced", func(t *testing.T) { + // Slow client exceeds the per-call timeout and is dropped, so quorum fails. + sa, sslow := newSigner(t), newSigner(t) + members := []AttestorMember{ + memberFor(t, &fakeClient{alias: "a", att: stateAtt(t, ctx, sa, height, height, ts)}, sa), + memberFor(t, &fakeClient{alias: "slow", att: stateAtt(t, ctx, sslow, height, height, ts), delay: time.Second}, sslow), + } + agg := must(NewAggregator(members, 2, 50*time.Millisecond)) + + start := time.Now() + _, err := agg.StateAttestation(ctx, height) + elapsed := time.Since(start) + + require.ErrorContains(t, err, "quorum not met") + assert.Less(t, elapsed, 500*time.Millisecond, "slow attestor should be cut off by per-call timeout") + }) +} + +func TestAggregatorLatestHeight(t *testing.T) { + ctx := context.Background() + + // heightMember wraps a height-only fakeClient with a unique expected address; + // LatestHeight never consults Expected, but construction still requires it. + heightMember := func(fc *fakeClient) AttestorMember { + return AttestorMember{Client: fc, Expected: uniqAddr(t)} + } + + t.Run("quorumHeightIgnoresLaggard", func(t *testing.T) { + // A single lagging-but-responsive attestor must not pin the height down + // while a quorum still leads: [1,100,101] with threshold 2 → 100. + members := []AttestorMember{ + heightMember(&fakeClient{alias: "a", height: 1}), + heightMember(&fakeClient{alias: "b", height: 100}), + heightMember(&fakeClient{alias: "c", height: 101}), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + height, err := agg.LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(100), height) + }) + + t.Run("thresholdEqualsClientsReturnsMin", func(t *testing.T) { + members := []AttestorMember{ + heightMember(&fakeClient{alias: "a", height: 100}), + heightMember(&fakeClient{alias: "b", height: 90}), + heightMember(&fakeClient{alias: "c", height: 110}), + } + agg := must(NewAggregator(members, 3, fastTimeout)) + + height, err := agg.LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(90), height) + }) + + t.Run("laggingDroppedButQuorumMet", func(t *testing.T) { + members := []AttestorMember{ + heightMember(&fakeClient{alias: "a", height: 100}), + heightMember(&fakeClient{alias: "b", heightErr: assertErr("down")}), + heightMember(&fakeClient{alias: "c", height: 105}), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + height, err := agg.LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(100), height) + }) + + t.Run("insufficientResponders", func(t *testing.T) { + members := []AttestorMember{ + heightMember(&fakeClient{alias: "a", height: 100}), + heightMember(&fakeClient{alias: "b", heightErr: assertErr("down")}), + heightMember(&fakeClient{alias: "c", heightErr: assertErr("down")}), + } + agg := must(NewAggregator(members, 2, fastTimeout)) + + _, err := agg.LatestHeight(ctx) + require.ErrorContains(t, err, "insufficient responders") + }) +} + +// assertErr is a tiny error constructor for tests. +func assertErr(msg string) error { return &testErr{msg} } + +type testErr struct{ msg string } + +func (e *testErr) Error() string { return e.msg } + +func must[T any](value T, err error) T { + if err != nil { + panic(err) + } + return value +} diff --git a/link/internal/relay/proofgen/client.go b/link/internal/relay/proofgen/client.go new file mode 100644 index 000000000..026a8a20b --- /dev/null +++ b/link/internal/relay/proofgen/client.go @@ -0,0 +1,195 @@ +// Package proofgen ports the proof-api aggregator: it fans out attestation +// requests to a configured set of attestor clients, enforces quorum by +// attested-data byte-equality, and assembles on-chain attestation proof blobs. +package proofgen + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/attestor" +) + +// AttestorMember pairs an attestor client with the on-chain signer address it is +// expected to recover to. The aggregator rejects any response whose recovered +// signer is not this address, so a mis-keyed endpoint ordered ahead of healthy +// members can never occupy a quorum slot (which would revert on-chain with +// UnknownSigner). The expected address is set-membership metadata, deliberately +// kept off the AttestorClient behavioral interface. +type AttestorMember struct { + Client AttestorClient + Expected common.Address +} + +// AttestorClient is a single attestor endpoint within an attestor set. Remote +// clients speak the connect RPCs; local clients route to an in-process service. +type AttestorClient interface { + // Alias identifies the client within its set; used for signature ordering + // and log/error attribution. + Alias() string + + // LatestAttestableHeight returns the highest height this attestor can attest to. + LatestAttestableHeight(ctx context.Context) (uint64, error) + + // StateAttestation requests a signed state attestation at height. + StateAttestation(ctx context.Context, height uint64) (*attestor.Attestation, error) + + // PacketAttestation requests a signed multi-packet attestation at height. + PacketAttestation( + ctx context.Context, + encodedPackets [][]byte, + height uint64, + proofType attestor.ProofType, + ) (*attestor.Attestation, error) +} + +// RemoteAttestor already speaks the AttestorClient contract over connect RPCs. +var _ AttestorClient = (*attestor.RemoteAttestor)(nil) + +// localClient routes AttestorClient calls to an in-process attestor.Service by alias. +type localClient struct { + svc *attestor.Service + alias string +} + +var _ AttestorClient = (*localClient)(nil) + +func (c *localClient) Alias() string { return c.alias } + +func (c *localClient) LatestAttestableHeight(ctx context.Context) (uint64, error) { + return c.svc.LatestAttestableHeight(ctx, c.alias) +} + +func (c *localClient) StateAttestation(ctx context.Context, height uint64) (*attestor.Attestation, error) { + return c.svc.StateAttestation(ctx, c.alias, height) +} + +func (c *localClient) PacketAttestation( + ctx context.Context, + encodedPackets [][]byte, + height uint64, + proofType attestor.ProofType, +) (*attestor.Attestation, error) { + return c.svc.PacketAttestation(ctx, attestor.PacketAttestationRequest{ + Attestor: c.alias, + Packets: encodedPackets, + Height: height, + ProofType: proofType, + }) +} + +// ClientsFromConfig builds the ordered AttestorMember set for an attestor set. +// Remote entries dial their gRPC endpoint; local entries route to the in-process +// service (an error is returned if a local entry is present but local is nil). +// Each member also carries the on-chain signer address the aggregator must see +// recovered from that endpoint's signatures (see expectedAddress). The returned +// slice preserves configured order, which fixes signature ordering. +// +// localSignerAddrs maps a signer alias to the EVM address of the ALREADY-LOADED +// in-process signer (built once at bootstrap). Local attestors with no explicit +// evmAddress resolve their expected address through it, so the identity the +// aggregator enforces is the very key the attestor service signs with — never a +// second read of the key file (which an atomic key rotation between reads could +// desynchronize, permanently splitting actual vs expected signer). +func ClientsFromConfig( + cfg config.Config, + set config.AttestorSetConfig, + local *attestor.Service, + localSignerAddrs map[string]common.Address, +) ([]AttestorMember, error) { + members := make([]AttestorMember, 0, len(set.Attestors)) + + for i, entry := range set.Attestors { + var client AttestorClient + switch entry.Type { + case config.AttestorTypeRemote: + if entry.GRPC == "" { + return nil, errors.Errorf("attestor[%d] %q: remote attestor requires a grpc endpoint", i, entry.Name) + } + client = attestor.NewRemoteFromURL(entry.Name, entry.GRPC) + + case config.AttestorTypeLocal: + if local == nil { + return nil, errors.Errorf( + "attestor[%d] %q: local attestor requested but no local service is configured", i, entry.Name) + } + client = &localClient{svc: local, alias: entry.Name} + + default: + return nil, errors.Errorf("attestor[%d] %q: unknown attestor type %q", i, entry.Name, entry.Type) + } + + expected, err := expectedAddress(cfg, entry, localSignerAddrs) + if err != nil { + return nil, errors.Wrapf(err, "attestor[%d] %q", i, entry.Name) + } + + members = append(members, AttestorMember{Client: client, Expected: expected}) + } + + if len(members) == 0 { + return nil, errors.New("attestor set contains no attestors") + } + + return members, nil +} + +// expectedAddress resolves the on-chain signer address the aggregator must see +// recovered from an attestor's signatures. It mirrors deploy's address +// resolution so the addresses the light client is deployed with are the same +// ones the relayer enforces at proof-assembly time: +// +// - entry.EVMAddress set -> parse it (empty-vs-invalid both surface here); +// - local, address unset -> look up the matching attestor.attestations[] +// entry's signer in localSignerAddrs (the address of the already-loaded +// in-process signer, not a fresh key-file read); +// - remote, address unset -> error: a remote attestor's address cannot be +// derived, so evmAddress is required (config validation also enforces this, +// but construction must never silently accept an empty address — an empty +// expected address would match nothing and freeze the set, or worse, an +// all-zero collision). +// +// A local attestation backed by a remote (KMS) signer is likewise underivable — +// it is absent from localSignerAddrs — and must set evmAddress; the error names +// the entry. +func expectedAddress( + cfg config.Config, entry config.AttestorEntry, localSignerAddrs map[string]common.Address, +) (common.Address, error) { + if entry.EVMAddress != "" { + if !common.IsHexAddress(entry.EVMAddress) { + return common.Address{}, errors.Errorf("evmAddress %q is not a valid hex address", entry.EVMAddress) + } + return common.HexToAddress(entry.EVMAddress), nil + } + + if entry.Type != config.AttestorTypeLocal { + return common.Address{}, errors.Errorf( + "remote attestor has no evmAddress; set evmAddress to its on-chain signer address") + } + + att, ok := attestationByName(cfg, entry.Name) + if !ok { + return common.Address{}, errors.Errorf( + "no attestor.attestations entry named %q to derive its EVM address; set evmAddress instead", entry.Name) + } + addr, ok := localSignerAddrs[att.Signer] + if !ok { + return common.Address{}, errors.Errorf( + "attestation %q signer %q has no locally derivable EVM address "+ + "(only local secp256k1 signers do); set evmAddress instead", entry.Name, att.Signer) + } + return addr, nil +} + +// attestationByName finds the attestor.attestations[] entry with the given name. +func attestationByName(cfg config.Config, name string) (config.AttestationConfig, bool) { + for _, a := range cfg.Attestor.Attestations { + if a.Name == name { + return a, true + } + } + return config.AttestationConfig{}, false +} diff --git a/link/internal/relay/proofgen/proofgen.go b/link/internal/relay/proofgen/proofgen.go new file mode 100644 index 000000000..1c1738bf2 --- /dev/null +++ b/link/internal/relay/proofgen/proofgen.go @@ -0,0 +1,213 @@ +package proofgen + +import ( + "context" + "strconv" + "sync" + + "github.com/pkg/errors" + "golang.org/x/sync/singleflight" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/pkg/mathx" + "github.com/cosmos/ibc/link/internal/service/attestor" +) + +// stateCacheMaxEntries bounds the per-Generator state-attestation cache. Once an +// attestation is obtained for a height it is immutable; the oldest heights are +// pruned when the cache grows past this bound. +const stateCacheMaxEntries = 1024 + +// Generator is the attestation-client ProofGenerator: it encodes inputs, obtains +// a quorum attestation from the aggregator, and assembles the on-chain proof blob. +// +// It enforces a single state attestation per (client, height): the on-chain +// attestation light client permanently freezes if two conflicting state +// attestations are ever submitted at the same height. Because generators are +// shared per "chainID/clientID" (i.e. per on-chain light client), the +// per-Generator state cache below is the correct dedup point — even across a +// reorg that would otherwise produce a second, conflicting attestation for an +// already-attested height, StateProof returns the first bytes it ever obtained. +// +// Ops note: this freeze guard is process-local by design. Running multiple +// relayer replicas against the same on-chain client is unsupported in V1 — +// nothing coordinates the caches across processes. This is safe because honest +// attestors at finalized heights produce deterministic attested data, so two +// replicas aggregating the same finalized height converge on identical bytes; +// the guard exists to defend against a reorg-induced second attestation within +// a single process, not to arbitrate between replicas. +type Generator struct { + agg *Aggregator + + // finalityOffset lags the reported attestable height so the relayer treats + // counterparty-finalized = latest - offset (ADR-011). + finalityOffset uint64 + + // stateGroup coalesces concurrent StateProof calls for the same height so a + // single aggregation is shared rather than racing to attest the same height. + stateGroup singleflight.Group + + mu sync.Mutex + stateCache map[uint64]stateResult + stateOrder []uint64 +} + +// stateResult is an immutable cached state attestation blob and its height. +type stateResult struct { + blob []byte + height uint64 +} + +// NewGenerator wraps an Aggregator as a ProofGenerator. finalityOffset lags the +// attestable height reported by LatestHeight (0 keeps the min-quorum height). +func NewGenerator(agg *Aggregator, finalityOffset uint64) (*Generator, error) { + if agg == nil { + return nil, errors.New("aggregator required") + } + return &Generator{ + agg: agg, + finalityOffset: finalityOffset, + stateCache: make(map[uint64]stateResult), + }, nil +} + +func (g *Generator) PacketProofs( + ctx context.Context, + height uint64, + kind chains.ProofKind, + packets []chains.Packet, +) ([][]byte, uint64, error) { + if len(packets) == 0 { + return nil, 0, errors.New("no packets to prove") + } + + proofType, err := proofTypeFor(kind) + if err != nil { + return nil, 0, err + } + + encoded := make([][]byte, len(packets)) + for i, packet := range packets { + raw, encErr := attestation.EncodePacket(packet) + if encErr != nil { + return nil, 0, errors.Wrapf(encErr, "encoding packet[%d]", i) + } + encoded[i] = raw + } + + aggregated, err := g.agg.PacketAttestation(ctx, encoded, height, proofType) + if err != nil { + return nil, 0, errors.Wrap(err, "aggregating packet attestation") + } + + blob, err := attestation.EncodeAttestationProof(aggregated.AttestedData, aggregated.Signatures) + if err != nil { + return nil, 0, errors.Wrap(err, "encoding attestation proof") + } + + // One aggregated attestation covers every packet; the same blob is attached + // to each per-packet relay message. + proofs := make([][]byte, len(packets)) + for i := range proofs { + proofs[i] = blob + } + + return proofs, aggregated.Height, nil +} + +// StateProof returns the state attestation blob for height, obtaining it at most +// once per height. Concurrent same-height callers share a single aggregation +// (singleflight); the first result is cached immutably so a later, conflicting +// attestation for the same height can never be produced and submitted. +func (g *Generator) StateProof(ctx context.Context, height uint64) ([]byte, uint64, error) { + if r, ok := g.cachedState(height); ok { + return r.blob, r.height, nil + } + + v, err, _ := g.stateGroup.Do(strconv.FormatUint(height, 10), func() (any, error) { + // Re-check the cache under the singleflight guard: a prior in-flight + // call for this height may have populated it. + if r, ok := g.cachedState(height); ok { + return r, nil + } + + aggregated, aggErr := g.agg.StateAttestation(ctx, height) + if aggErr != nil { + return nil, errors.Wrap(aggErr, "aggregating state attestation") + } + + blob, encErr := attestation.EncodeAttestationProof(aggregated.AttestedData, aggregated.Signatures) + if encErr != nil { + return nil, errors.Wrap(encErr, "encoding attestation proof") + } + + return g.storeState(height, stateResult{blob: blob, height: aggregated.Height}), nil + }) + if err != nil { + return nil, 0, err + } + + r := v.(stateResult) + + return r.blob, r.height, nil +} + +// cachedState returns the immutable cached state attestation for height, if any. +func (g *Generator) cachedState(height uint64) (stateResult, bool) { + g.mu.Lock() + defer g.mu.Unlock() + + r, ok := g.stateCache[height] + + return r, ok +} + +// storeState records the state attestation for height. It is immutable: if a +// value already exists for the height, the existing one is kept and returned so +// the client is never offered two attestations for one height. The cache is +// pruned in insertion order once it exceeds stateCacheMaxEntries. +func (g *Generator) storeState(height uint64, r stateResult) stateResult { + g.mu.Lock() + defer g.mu.Unlock() + + if existing, ok := g.stateCache[height]; ok { + return existing + } + + g.stateCache[height] = r + g.stateOrder = append(g.stateOrder, height) + + for len(g.stateOrder) > stateCacheMaxEntries { + oldest := g.stateOrder[0] + g.stateOrder = g.stateOrder[1:] + delete(g.stateCache, oldest) + } + + return r +} + +// LatestHeight returns the min-quorum attestable height, lagged by the +// configured counterparty finality offset (saturating at 0). +func (g *Generator) LatestHeight(ctx context.Context) (uint64, error) { + height, err := g.agg.LatestHeight(ctx) + if err != nil { + return 0, err + } + + return mathx.SaturatingSubU64(height, g.finalityOffset), nil +} + +// proofTypeFor maps a chain-level proof kind to an attestor proof type. +func proofTypeFor(kind chains.ProofKind) (attestor.ProofType, error) { + switch kind { + case chains.KindPacketCommitment: + return attestor.ProofTypePacketCommitment, nil + case chains.KindAcknowledgement: + return attestor.ProofTypePacketAcknowledgement, nil + case chains.KindReceiptAbsence: + return attestor.ProofTypePacketReceiptAbsence, nil + default: + return 0, errors.Errorf("unknown proof kind %d", kind) + } +} diff --git a/link/internal/relay/proofgen/proofgen_test.go b/link/internal/relay/proofgen/proofgen_test.go new file mode 100644 index 000000000..bc6e11b2a --- /dev/null +++ b/link/internal/relay/proofgen/proofgen_test.go @@ -0,0 +1,484 @@ +package proofgen + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/attestor" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +func testPacket(seq uint64) chains.Packet { + return chains.Packet{ + Sequence: seq, + SourceClient: "client-src", + DestClient: "client-dst", + TimeoutTimestamp: 1_700_000_000, + Payloads: []chains.Payload{{ + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/json", + Value: []byte("payload"), + }}, + } +} + +func TestGeneratorProofKindMapping(t *testing.T) { + for _, tt := range []struct { + kind chains.ProofKind + want attestor.ProofType + }{ + {chains.KindPacketCommitment, attestor.ProofTypePacketCommitment}, + {chains.KindAcknowledgement, attestor.ProofTypePacketAcknowledgement}, + {chains.KindReceiptAbsence, attestor.ProofTypePacketReceiptAbsence}, + } { + got, err := proofTypeFor(tt.kind) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + + _, err := proofTypeFor(chains.ProofKind(99)) + require.ErrorContains(t, err, "unknown proof kind") +} + +// TestGeneratorPacketProofs proves the proof blob round-trips and that one blob +// is repeated per packet with the attested height propagated. +func TestGeneratorPacketProofs(t *testing.T) { + ctx := context.Background() + + // Build a real packet attestation with two signers to exercise round-tripping. + const height = 50 + packets := []chains.Packet{testPacket(1), testPacket(2)} + + compacts := make([]attestation.PacketCompact, len(packets)) + for i, p := range packets { + compacts[i] = attestation.PacketCompact{ + Path: attestation.PathHash(attestation.PacketCommitmentPath(p.SourceClient, p.Sequence)), + Commitment: attestation.PacketCommitment(p), + } + } + attestedData, err := attestation.EncodePacketAttestation(height, compacts) + require.NoError(t, err) + + signerA := must(signer.GenerateLocalSecp256k1Signer()) + signerB := must(signer.GenerateLocalSecp256k1Signer()) + sigA := signWith(t, ctx, signerA, attestedData) + sigB := signWith(t, ctx, signerB, attestedData) + + clients := []AttestorMember{ + { + Client: &fakeClient{alias: "a", att: &attestor.Attestation{Height: height, AttestedData: attestedData, Signature: sigA}}, + Expected: addressOf(t, signerA), + }, + { + Client: &fakeClient{alias: "b", att: &attestor.Attestation{Height: height, AttestedData: attestedData, Signature: sigB}}, + Expected: addressOf(t, signerB), + }, + } + gen := must(NewGenerator(must(NewAggregator(clients, 2, fastTimeout)), 0)) + + proofs, attHeight, err := gen.PacketProofs(ctx, height, chains.KindPacketCommitment, packets) + require.NoError(t, err) + assert.Equal(t, uint64(height), attHeight) + require.Len(t, proofs, len(packets)) + assert.Equal(t, proofs[0], proofs[1], "all packets share the same aggregated blob") + + // Round-trip the blob and recover both signers. + gotData, gotSigs, err := attestation.DecodeAttestationProof(proofs[0]) + require.NoError(t, err) + assert.Equal(t, attestedData, gotData) + require.Len(t, gotSigs, 2) + + digest := attestation.Digest(attestation.TypePacket, gotData) + assert.Equal(t, addressOf(t, signerA), recoverAddr(t, digest, gotSigs[0])) + assert.Equal(t, addressOf(t, signerB), recoverAddr(t, digest, gotSigs[1])) +} + +func TestGeneratorPacketProofsEmpty(t *testing.T) { + members := []AttestorMember{{Client: &fakeClient{alias: "a"}, Expected: uniqAddr(t)}} + gen := must(NewGenerator(must(NewAggregator(members, 1, fastTimeout)), 0)) + _, _, err := gen.PacketProofs(context.Background(), 50, chains.KindPacketCommitment, nil) + require.ErrorContains(t, err, "no packets") +} + +func TestGeneratorStateProof(t *testing.T) { + ctx := context.Background() + const height = 50 + ts := uint64(1_700_000_500) + attestedData := attestation.EncodeStateAttestation(height, ts) + + signerA := must(signer.GenerateLocalSecp256k1Signer()) + sigA := signState(t, ctx, signerA, attestedData) + + clients := []AttestorMember{ + { + Client: &fakeClient{alias: "a", att: &attestor.Attestation{ + Height: height, Timestamp: &ts, AttestedData: attestedData, Signature: sigA, + }}, + Expected: addressOf(t, signerA), + }, + } + gen := must(NewGenerator(must(NewAggregator(clients, 1, fastTimeout)), 0)) + + blob, attHeight, err := gen.StateProof(ctx, height) + require.NoError(t, err) + assert.Equal(t, uint64(height), attHeight) + + gotData, gotSigs, err := attestation.DecodeAttestationProof(blob) + require.NoError(t, err) + assert.Equal(t, attestedData, gotData) + require.Len(t, gotSigs, 1) + + digest := attestation.Digest(attestation.TypeState, gotData) + assert.Equal(t, addressOf(t, signerA), recoverAddr(t, digest, gotSigs[0])) +} + +// TestGeneratorLocalIntegration wires the Generator over a real attestor.Service +// backed by local attestors and a fake chain adapter, proving in-process wiring +// produces on-chain-valid proofs end to end. +func TestGeneratorLocalIntegration(t *testing.T) { + ctx := context.Background() + const height = 50 + + pkt := testPacket(7) + key := attestation.PathHash(attestation.PacketCommitmentPath(pkt.SourceClient, pkt.Sequence)) + commitment := attestation.PacketCommitment(pkt) + + // Three local attestors sharing one chain adapter, distinct signers/aliases. + adapter := &fakeChainAdapter{ + finalizedHeight: 100, + timestamp: time.Unix(1_700_000_500, 0), + commitments: map[[32]byte][32]byte{key: commitment}, + } + + aliases := []string{"alice", "bob", "carol"} + signers := make(map[string]*signer.LocalSecp256k1Signer, len(aliases)) + attestors := make([]attestor.Attestor, 0, len(aliases)) + for _, alias := range aliases { + sgn := must(signer.GenerateLocalSecp256k1Signer()) + signers[alias] = sgn + attestors = append(attestors, must(attestor.NewLocal("chain-1", alias, sgn, adapter))) + } + svc := must(attestor.New(attestors, nil)) + + // The attestors are built from in-memory signers (not config key files), so the + // entries carry explicit evmAddress values; ClientsFromConfig uses them directly + // and the empty config below is never consulted for local derivation. + set := config.AttestorSetConfig{ + Threshold: 2, + Attestors: []config.AttestorEntry{ + {Name: "alice", Type: config.AttestorTypeLocal, EVMAddress: addressOf(t, signers["alice"]).Hex()}, + {Name: "bob", Type: config.AttestorTypeLocal, EVMAddress: addressOf(t, signers["bob"]).Hex()}, + {Name: "carol", Type: config.AttestorTypeLocal, EVMAddress: addressOf(t, signers["carol"]).Hex()}, + }, + } + + proofClients, err := ClientsFromConfig(config.Config{}, set, svc, nil) + require.NoError(t, err) + require.Len(t, proofClients, 3) + + gen := must(NewGenerator(must(NewAggregator(proofClients, set.Threshold, fastTimeout)), 0)) + + // LatestHeight reflects the adapter's finalized height. + latest, err := gen.LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(100), latest) + + // Packet proofs round-trip and recover to the configured signers. + proofs, attHeight, err := gen.PacketProofs(ctx, height, chains.KindPacketCommitment, []chains.Packet{pkt}) + require.NoError(t, err) + assert.Equal(t, uint64(height), attHeight) + require.Len(t, proofs, 1) + + gotData, gotSigs, err := attestation.DecodeAttestationProof(proofs[0]) + require.NoError(t, err) + // All three attestors agree, but the proof is truncated to exactly the threshold + // (2) in configured order: the client accepts >= threshold, yet every submitted + // signature must recover to a known, non-duplicate attestor, so we submit the + // fewest it accepts and let a faulty extra member never enter the proof. + require.Len(t, gotSigs, set.Threshold) + + // Signatures follow configured order (alice, bob) and recover to those signers. + digest := attestation.Digest(attestation.TypePacket, gotData) + assert.Equal(t, addressOf(t, signers["alice"]), recoverAddr(t, digest, gotSigs[0])) + assert.Equal(t, addressOf(t, signers["bob"]), recoverAddr(t, digest, gotSigs[1])) + + // State proof round-trips too. + stateBlob, stateHeight, err := gen.StateProof(ctx, height) + require.NoError(t, err) + assert.Equal(t, uint64(height), stateHeight) + stateData, stateSigs, err := attestation.DecodeAttestationProof(stateBlob) + require.NoError(t, err) + require.Len(t, stateSigs, set.Threshold) + stateDigest := attestation.Digest(attestation.TypeState, stateData) + assert.Equal(t, addressOf(t, signers["alice"]), recoverAddr(t, stateDigest, stateSigs[0])) + assert.Equal(t, addressOf(t, signers["bob"]), recoverAddr(t, stateDigest, stateSigs[1])) +} + +// TestGeneratorStateProofCachedPerHeight proves that concurrent StateProof calls +// for the same height share a single upstream aggregation and return identical +// bytes, while different heights are aggregated independently. This is the dedup +// point that prevents two conflicting state attestations at one height (which +// would permanently freeze the on-chain light client). +func TestGeneratorStateProofCachedPerHeight(t *testing.T) { + ctx := context.Background() + const height = 50 + ts := uint64(1_700_000_500) + attestedData := attestation.EncodeStateAttestation(height, ts) + + signerA := must(signer.GenerateLocalSecp256k1Signer()) + sigA := signState(t, ctx, signerA, attestedData) + + client := &fakeClient{alias: "a", delay: 20 * time.Millisecond, att: &attestor.Attestation{ + Height: height, Timestamp: &ts, AttestedData: attestedData, Signature: sigA, + }} + members := []AttestorMember{{Client: client, Expected: addressOf(t, signerA)}} + gen := must(NewGenerator(must(NewAggregator(members, 1, fastTimeout)), 0)) + + const concurrency = 8 + var wg sync.WaitGroup + blobs := make([][]byte, concurrency) + for i := range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + blob, h, err := gen.StateProof(ctx, height) + require.NoError(t, err) + assert.Equal(t, uint64(height), h) + blobs[i] = blob + }() + } + wg.Wait() + + // All concurrent callers observe identical bytes. + for i := 1; i < concurrency; i++ { + assert.Equal(t, blobs[0], blobs[i], "same height must yield identical attestation bytes") + } + // The upstream attestor was hit exactly once for the shared height. + assert.Equal(t, int64(1), client.calls.Load(), "same-height requests must dedupe to one upstream call") + + // A subsequent call for the same height is served from cache (no new call). + _, _, err := gen.StateProof(ctx, height) + require.NoError(t, err) + assert.Equal(t, int64(1), client.calls.Load()) + + // A different height triggers an independent aggregation. + const height2 = 60 + ts2 := uint64(1_700_000_600) + data2 := attestation.EncodeStateAttestation(height2, ts2) + client.att = &attestor.Attestation{ + Height: height2, Timestamp: &ts2, AttestedData: data2, + Signature: signState(t, ctx, signerA, data2), + } + _, h2, err := gen.StateProof(ctx, height2) + require.NoError(t, err) + assert.Equal(t, uint64(height2), h2) + assert.Equal(t, int64(2), client.calls.Load(), "different heights are independent") +} + +// TestGeneratorLatestHeightFinalityOffset proves the finality offset lags the +// reported attestable height (saturating at zero). +func TestGeneratorLatestHeightFinalityOffset(t *testing.T) { + ctx := context.Background() + + newGen := func(offset uint64) *Generator { + members := []AttestorMember{{Client: &fakeClient{alias: "a", height: 100}, Expected: uniqAddr(t)}} + return must(NewGenerator(must(NewAggregator(members, 1, fastTimeout)), offset)) + } + + h, err := newGen(0).LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(100), h) + + h, err = newGen(10).LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(90), h) + + // Offset larger than the height saturates to zero. + h, err = newGen(250).LatestHeight(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(0), h) +} + +func TestClientsFromConfig(t *testing.T) { + const evmAddr = "0x00000000000000000000000000000000000000Aa" + + t.Run("remoteNeedsNoLocalService", func(t *testing.T) { + set := config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "remote-a", Type: config.AttestorTypeRemote, GRPC: "http://localhost:1", EVMAddress: evmAddr}, + }, + } + members, err := ClientsFromConfig(config.Config{}, set, nil, nil) + require.NoError(t, err) + require.Len(t, members, 1) + assert.Equal(t, "remote-a", members[0].Client.Alias()) + assert.Equal(t, common.HexToAddress(evmAddr), members[0].Expected) + }) + + t.Run("remoteWithoutEVMAddressErrors", func(t *testing.T) { + // A remote attestor's on-chain address cannot be derived, so construction + // must reject an empty evmAddress rather than carry a zero expected address. + set := config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "remote-a", Type: config.AttestorTypeRemote, GRPC: "http://localhost:1"}, + }, + } + _, err := ClientsFromConfig(config.Config{}, set, nil, nil) + require.ErrorContains(t, err, "remote-a") + require.ErrorContains(t, err, "evmAddress") + }) + + t.Run("invalidEVMAddressErrors", func(t *testing.T) { + set := config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "remote-a", Type: config.AttestorTypeRemote, GRPC: "http://localhost:1", EVMAddress: "not-hex"}, + }, + } + _, err := ClientsFromConfig(config.Config{}, set, nil, nil) + require.ErrorContains(t, err, "not a valid hex address") + }) + + t.Run("localWithoutServiceErrors", func(t *testing.T) { + set := config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{{Name: "dan", Type: config.AttestorTypeLocal, EVMAddress: evmAddr}}, + } + _, err := ClientsFromConfig(config.Config{}, set, nil, nil) + require.ErrorContains(t, err, "no local service") + }) + + t.Run("remoteWithoutGRPCErrors", func(t *testing.T) { + set := config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{{Name: "r", Type: config.AttestorTypeRemote, EVMAddress: evmAddr}}, + } + _, err := ClientsFromConfig(config.Config{}, set, nil, nil) + require.ErrorContains(t, err, "grpc endpoint") + }) +} + +// TestClientsFromConfigLocalDerivation proves a local attestor entry with no +// explicit evmAddress has its expected on-chain address resolved from the +// already-loaded signer backing its matching attestor.attestations[].signer, +// injected as localSignerAddrs — never a fresh key-file read (which an atomic key +// rotation between reads could desynchronize). The address the relayer enforces at +// proof-assembly time is thus exactly the key the in-process attestor signs with. +func TestClientsFromConfigLocalDerivation(t *testing.T) { + adapter := &fakeChainAdapter{finalizedHeight: 100, timestamp: time.Unix(1_700_000_500, 0)} + + aliases := []string{"alice", "bob"} + signers := make(map[string]*signer.LocalSecp256k1Signer, len(aliases)) + attestors := make([]attestor.Attestor, 0, len(aliases)) + var attestations []config.AttestationConfig + localSignerAddrs := make(map[string]common.Address) + for _, alias := range aliases { + sgn := must(signer.GenerateLocalSecp256k1Signer()) + signers[alias] = sgn + + // The signer is already loaded in-process; its address is injected via + // localSignerAddrs rather than re-derived from a key file. + localSignerAddrs[alias+"-key"] = addressOf(t, sgn) + attestations = append(attestations, config.AttestationConfig{Name: alias, Signer: alias + "-key"}) + attestors = append(attestors, must(attestor.NewLocal("chain-1", alias, sgn, adapter))) + } + svc := must(attestor.New(attestors, nil)) + + cfg := config.Config{Attestor: config.AttestorConfig{Attestations: attestations}} + set := config.AttestorSetConfig{ + Threshold: 2, + Attestors: []config.AttestorEntry{ + {Name: "alice", Type: config.AttestorTypeLocal}, // no evmAddress -> resolved from loaded signer + {Name: "bob", Type: config.AttestorTypeLocal}, + }, + } + + members, err := ClientsFromConfig(cfg, set, svc, localSignerAddrs) + require.NoError(t, err) + require.Len(t, members, 2) + assert.Equal(t, addressOf(t, signers["alice"]), members[0].Expected) + assert.Equal(t, addressOf(t, signers["bob"]), members[1].Expected) + + t.Run("underivableRemoteSignerRequiresEVMAddress", func(t *testing.T) { + // A local attestation backed by a remote (KMS) signer is absent from + // localSignerAddrs (only local secp256k1 signers are injected); construction + // must name the entry and demand an explicit evmAddress. + remoteCfg := config.Config{ + Attestor: config.AttestorConfig{Attestations: []config.AttestationConfig{{Name: "alice", Signer: "kms"}}}, + } + _, err := ClientsFromConfig(remoteCfg, set, svc, nil) + require.ErrorContains(t, err, "alice") + require.ErrorContains(t, err, "evmAddress") + }) +} + +// --- test helpers --- + +func signWith(t *testing.T, ctx context.Context, s *signer.LocalSecp256k1Signer, data []byte) []byte { + t.Helper() + raw, err := s.Sign(ctx, attestation.SigningInput(attestation.TypePacket, data)) + require.NoError(t, err) + sig, err := attestation.NormalizeV(raw) + require.NoError(t, err) + return sig +} + +func signState(t *testing.T, ctx context.Context, s *signer.LocalSecp256k1Signer, data []byte) []byte { + t.Helper() + raw, err := s.Sign(ctx, attestation.SigningInput(attestation.TypeState, data)) + require.NoError(t, err) + sig, err := attestation.NormalizeV(raw) + require.NoError(t, err) + return sig +} + +func addressOf(t *testing.T, s *signer.LocalSecp256k1Signer) common.Address { + t.Helper() + pub, err := crypto.DecompressPubkey(s.PublicKey()) + require.NoError(t, err) + return crypto.PubkeyToAddress(*pub) +} + +func recoverAddr(t *testing.T, digest [32]byte, sig []byte) common.Address { + t.Helper() + addr, err := attestation.RecoverSigner(digest, sig) + require.NoError(t, err) + return addr +} + +// fakeChainAdapter implements attestor.ChainAdapter for the integration test. +type fakeChainAdapter struct { + finalizedHeight uint64 + timestamp time.Time + commitments map[[32]byte][32]byte +} + +var _ attestor.ChainAdapter = (*fakeChainAdapter)(nil) + +func (f *fakeChainAdapter) FinalizedHeight(context.Context) (uint64, error) { + return f.finalizedHeight, nil +} + +func (f *fakeChainAdapter) HeaderTimestamp(context.Context, uint64) (time.Time, error) { + return f.timestamp, nil +} + +func (f *fakeChainAdapter) GetCommitment(_ context.Context, hashedPath [32]byte, _ uint64) ([32]byte, error) { + return f.commitments[hashedPath], nil +} + +func (f *fakeChainAdapter) Close() error { return nil } diff --git a/link/internal/server/attestor_handler.go b/link/internal/server/attestor_handler.go index 818099caf..978b5c8e9 100644 --- a/link/internal/server/attestor_handler.go +++ b/link/internal/server/attestor_handler.go @@ -10,7 +10,7 @@ import ( "github.com/cosmos/ibc/link/internal/service/attestor" - proto "github.com/cosmos/ibc/link/internal/types/v2/attestor" + proto "github.com/cosmos/ibc/link/api/v2/attestor" ) // AttestorHandler handles attestor RPC requests. @@ -22,6 +22,8 @@ type AttestorHandler struct { // AttestorService defines attestor business logic. type AttestorService interface { LatestAttestableHeight(ctx context.Context, attestor string) (uint64, error) + StateAttestation(ctx context.Context, attestor string, height uint64) (*attestor.Attestation, error) + PacketAttestation(ctx context.Context, req attestor.PacketAttestationRequest) (*attestor.Attestation, error) } var ( @@ -49,14 +51,88 @@ func (h *AttestorHandler) LatestAttestableHeight( req *connect.Request[proto.LatestAttestableHeightRequest], ) (*connect.Response[proto.LatestAttestableHeightResponse], error) { height, err := h.srv.LatestAttestableHeight(ctx, req.Msg.Attestor) - switch { - case errors.Is(err, attestor.ErrNotFound): - return nil, connect.NewError(connect.CodeNotFound, err) - case err != nil: - // todo: move to interceptor - h.logger.Error("LatestAttestableHeight", "error", err) - return nil, errInternal + if err != nil { + return nil, h.mapError("LatestAttestableHeight", err) } return connect.NewResponse(&proto.LatestAttestableHeightResponse{Height: height}), nil } + +func (h *AttestorHandler) StateAttestation( + ctx context.Context, + req *connect.Request[proto.StateAttestationRequest], +) (*connect.Response[proto.StateAttestationResponse], error) { + att, err := h.srv.StateAttestation(ctx, req.Msg.Attestor, req.Msg.Height) + if err != nil { + return nil, h.mapError("StateAttestation", err) + } + + return connect.NewResponse(&proto.StateAttestationResponse{ + Attestation: attestationToProto(att), + }), nil +} + +func (h *AttestorHandler) PacketAttestation( + ctx context.Context, + req *connect.Request[proto.PacketAttestationRequest], +) (*connect.Response[proto.PacketAttestationResponse], error) { + proofType, err := proofTypeFromProto(req.Msg.ProofType) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + att, err := h.srv.PacketAttestation(ctx, attestor.PacketAttestationRequest{ + Attestor: req.Msg.Attestor, + Packets: req.Msg.Packets, + Height: req.Msg.Height, + ProofType: proofType, + }) + if err != nil { + return nil, h.mapError("PacketAttestation", err) + } + + return connect.NewResponse(&proto.PacketAttestationResponse{ + Attestation: attestationToProto(att), + }), nil +} + +// mapError maps domain errors to connect codes, mirroring the Rust attestor semantics. +func (h *AttestorHandler) mapError(method string, err error) error { + switch { + case errors.Is(err, attestor.ErrNotFound), errors.Is(err, attestor.ErrCommitmentNotFound): + return connect.NewError(connect.CodeNotFound, err) + case errors.Is(err, attestor.ErrInvalidInput): + return connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, attestor.ErrNotFinalized): + return connect.NewError(connect.CodeFailedPrecondition, err) + default: + h.logger.Error(method, "err", err) + return errInternal + } +} + +func attestationToProto(att *attestor.Attestation) *proto.Attestation { + if att == nil { + return nil + } + + return &proto.Attestation{ + Height: att.Height, + Timestamp: att.Timestamp, + AttestedData: att.AttestedData, + Signature: att.Signature, + } +} + +func proofTypeFromProto(pt proto.ProofType) (attestor.ProofType, error) { + switch pt { + case proto.ProofType_PROOF_TYPE_PACKET_COMMITMENT: + return attestor.ProofTypePacketCommitment, nil + case proto.ProofType_PROOF_TYPE_PACKET_ACKNOWLEDGEMENT: + return attestor.ProofTypePacketAcknowledgement, nil + case proto.ProofType_PROOF_TYPE_PACKET_RECEIPT_ABSENCE: + return attestor.ProofTypePacketReceiptAbsence, nil + default: + return 0, errors.Errorf("unknown proof type %d", pt) + } +} diff --git a/link/internal/server/attestor_handler_test.go b/link/internal/server/attestor_handler_test.go new file mode 100644 index 000000000..7c4c0be02 --- /dev/null +++ b/link/internal/server/attestor_handler_test.go @@ -0,0 +1,164 @@ +package server + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/service/attestor" + proto "github.com/cosmos/ibc/link/api/v2/attestor" +) + +// fakeAttestorService is a hand-rolled AttestorService stub for handler tests. +type fakeAttestorService struct { + att *attestor.Attestation + err error +} + +func (f *fakeAttestorService) LatestAttestableHeight(context.Context, string) (uint64, error) { + if f.err != nil { + return 0, f.err + } + return 42, nil +} + +func (f *fakeAttestorService) StateAttestation(context.Context, string, uint64) (*attestor.Attestation, error) { + return f.att, f.err +} + +func (f *fakeAttestorService) PacketAttestation(context.Context, attestor.PacketAttestationRequest) (*attestor.Attestation, error) { + return f.att, f.err +} + +func TestAttestorHandlerErrorMapping(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + err error + wantCode connect.Code + }{ + { + name: "not found", + err: attestor.ErrNotFound, + wantCode: connect.CodeNotFound, + }, + { + name: "commitment not found", + err: errors.Wrap(attestor.ErrCommitmentNotFound, "commitment not found client_id=x sequence=1 at height=2"), + wantCode: connect.CodeNotFound, + }, + { + name: "invalid input", + err: errors.Wrap(attestor.ErrInvalidInput, "bad packet"), + wantCode: connect.CodeInvalidArgument, + }, + { + name: "not finalized", + err: errors.Wrap(attestor.ErrNotFinalized, "height too high"), + wantCode: connect.CodeFailedPrecondition, + }, + { + name: "internal", + err: errors.New("signer exploded"), + wantCode: connect.CodeInternal, + }, + } { + t.Run(tt.name, func(t *testing.T) { + handler := NewAttestorHandler(&fakeAttestorService{err: tt.err}) + + t.Run("StateAttestation", func(t *testing.T) { + _, err := handler.StateAttestation(ctx, connect.NewRequest(&proto.StateAttestationRequest{ + Attestor: "alice", + Height: 10, + })) + require.Error(t, err) + assert.Equal(t, tt.wantCode, connect.CodeOf(err)) + }) + + t.Run("PacketAttestation", func(t *testing.T) { + _, err := handler.PacketAttestation(ctx, connect.NewRequest(&proto.PacketAttestationRequest{ + Attestor: "alice", + Packets: [][]byte{{0x01}}, + Height: 10, + ProofType: proto.ProofType_PROOF_TYPE_PACKET_COMMITMENT, + })) + require.Error(t, err) + assert.Equal(t, tt.wantCode, connect.CodeOf(err)) + }) + + t.Run("LatestAttestableHeight", func(t *testing.T) { + _, err := handler.LatestAttestableHeight(ctx, connect.NewRequest(&proto.LatestAttestableHeightRequest{ + Attestor: "alice", + })) + require.Error(t, err) + assert.Equal(t, tt.wantCode, connect.CodeOf(err)) + }) + }) + } +} + +func TestAttestorHandlerHappyPath(t *testing.T) { + ctx := context.Background() + ts := uint64(1_700_000_000) + + t.Run("StateAttestation", func(t *testing.T) { + handler := NewAttestorHandler(&fakeAttestorService{att: &attestor.Attestation{ + Height: 10, + Timestamp: &ts, + AttestedData: []byte("data"), + Signature: []byte("sig"), + }}) + + res, err := handler.StateAttestation(ctx, connect.NewRequest(&proto.StateAttestationRequest{ + Attestor: "alice", + Height: 10, + })) + + require.NoError(t, err) + require.NotNil(t, res.Msg.Attestation) + assert.Equal(t, uint64(10), res.Msg.Attestation.Height) + require.NotNil(t, res.Msg.Attestation.Timestamp) + assert.Equal(t, ts, *res.Msg.Attestation.Timestamp) + assert.Equal(t, []byte("data"), res.Msg.Attestation.AttestedData) + assert.Equal(t, []byte("sig"), res.Msg.Attestation.Signature) + }) + + t.Run("PacketAttestation absent timestamp", func(t *testing.T) { + handler := NewAttestorHandler(&fakeAttestorService{att: &attestor.Attestation{ + Height: 10, + Timestamp: nil, + AttestedData: []byte("data"), + Signature: []byte("sig"), + }}) + + res, err := handler.PacketAttestation(ctx, connect.NewRequest(&proto.PacketAttestationRequest{ + Attestor: "alice", + Packets: [][]byte{{0x01}}, + Height: 10, + ProofType: proto.ProofType_PROOF_TYPE_PACKET_ACKNOWLEDGEMENT, + })) + + require.NoError(t, err) + require.NotNil(t, res.Msg.Attestation) + assert.Nil(t, res.Msg.Attestation.Timestamp) + }) + + t.Run("PacketAttestation invalid proof type", func(t *testing.T) { + handler := NewAttestorHandler(&fakeAttestorService{}) + + _, err := handler.PacketAttestation(ctx, connect.NewRequest(&proto.PacketAttestationRequest{ + Attestor: "alice", + Packets: [][]byte{{0x01}}, + Height: 10, + ProofType: proto.ProofType(99), + })) + + require.Error(t, err) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + }) +} diff --git a/link/internal/server/interceptor.go b/link/internal/server/interceptor.go new file mode 100644 index 000000000..37ff3516f --- /dev/null +++ b/link/internal/server/interceptor.go @@ -0,0 +1,35 @@ +package server + +import ( + "context" + "time" + + "connectrpc.com/connect" + + "github.com/cosmos/ibc/link/internal/metrics" +) + +// MetricsInterceptor builds a Connect interceptor that records RPC counts and +// durations by procedure and result code into m. Streaming is passed through +// unmeasured (UnaryInterceptorFunc); the daemon only serves unary RPCs. +func MetricsInterceptor(m *metrics.Metrics) connect.Interceptor { + return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + start := time.Now() + res, err := next(ctx, req) + m.ObserveRPC(req.Spec().Procedure, codeString(err), time.Since(start)) + + return res, err + } + }) +} + +// codeString reports the Connect result code label: "ok" on success, else the +// canonical code string (e.g. "not_found"). +func codeString(err error) string { + if err == nil { + return "ok" + } + + return connect.CodeOf(err).String() +} diff --git a/link/internal/server/interceptor_test.go b/link/internal/server/interceptor_test.go new file mode 100644 index 000000000..a83ac9540 --- /dev/null +++ b/link/internal/server/interceptor_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "context" + "strings" + "testing" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/metrics" + proto "github.com/cosmos/ibc/link/api/v2/relayer" +) + +// TestMetricsInterceptor drives the unary interceptor directly and asserts it +// records one RPC sample per distinct result code. The end-to-end wiring +// (serverOptions + /metrics + health) is covered in the bootstrap package. +func TestMetricsInterceptor(t *testing.T) { + m := metrics.New() + interceptor := MetricsInterceptor(m) + + call := func(retErr error) { + next := connect.UnaryFunc(func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) { + return nil, retErr + }) + _, _ = interceptor.WrapUnary(next)(context.Background(), connect.NewRequest(&proto.RelayRequest{})) + } + + call(nil) + call(connect.NewError(connect.CodeNotFound, errors.New("missing"))) + + // Two distinct code labels ("ok", "not_found") -> two counter series. + count, err := testutil.GatherAndCount(m.Registry(), "ibclink_rpc_requests_total") + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCodeString(t *testing.T) { + assert.Equal(t, "ok", codeString(nil)) + assert.Equal(t, "not_found", codeString(connect.NewError(connect.CodeNotFound, errors.New("x")))) + assert.True(t, strings.HasPrefix(codeString(errors.New("plain")), "unknown")) +} diff --git a/link/internal/server/relayer_handler.go b/link/internal/server/relayer_handler.go index 15bafaf7b..2097b8b5b 100644 --- a/link/internal/server/relayer_handler.go +++ b/link/internal/server/relayer_handler.go @@ -10,7 +10,7 @@ import ( "github.com/cosmos/ibc/link/internal/service/relayer" - proto "github.com/cosmos/ibc/link/internal/types/v2/relayer" + proto "github.com/cosmos/ibc/link/api/v2/relayer" ) // RelayerHandler handles relayer RPC requests. @@ -21,7 +21,8 @@ type RelayerHandler struct { // RelayerService defines relayer business logic. type RelayerService interface { - Relay(ctx context.Context, chainID string, txHash string) error + Relay(ctx context.Context, chainID string, txHash string) ([]relayer.PacketStatus, error) + Status(ctx context.Context, chainID string, txHash string) ([]relayer.PacketStatus, error) } var ( @@ -48,15 +49,75 @@ func (h *RelayerHandler) Relay( ctx context.Context, req *connect.Request[proto.RelayRequest], ) (*connect.Response[proto.RelayResponse], error) { - err := h.srv.Relay(ctx, req.Msg.ChainId, req.Msg.TxHash) + if _, err := h.srv.Relay(ctx, req.Msg.ChainId, req.Msg.TxHash); err != nil { + return nil, h.mapError("Relay", err) + } + + return connect.NewResponse(&proto.RelayResponse{}), nil +} + +func (h *RelayerHandler) Status( + ctx context.Context, + req *connect.Request[proto.StatusRequest], +) (*connect.Response[proto.StatusResponse], error) { + statuses, err := h.srv.Status(ctx, req.Msg.ChainId, req.Msg.TxHash) + if err != nil { + return nil, h.mapError("Status", err) + } + + packetStatuses := make([]*proto.PacketStatus, len(statuses)) + for i, status := range statuses { + packetStatuses[i] = &proto.PacketStatus{ + State: packetStateToProto(status.State), + SequenceNumber: status.SequenceNumber, + SourceClientId: status.SourceClientID, + SendTx: txInfoToProto(&status.SendTx), + RecvTx: txInfoToProto(status.RecvTx), + AckTx: txInfoToProto(status.AckTx), + TimeoutTx: txInfoToProto(status.TimeoutTx), + } + } + + return connect.NewResponse(&proto.StatusResponse{PacketStatuses: packetStatuses}), nil +} + +// mapError maps domain errors to connect codes. ErrUpstream mirrors the HTTP +// adapter's 502 Bad Gateway, whose Connect analog is CodeUnavailable. +func (h *RelayerHandler) mapError(method string, err error) error { switch { case errors.Is(err, relayer.ErrInvalidInput): - return nil, connect.NewError(connect.CodeInvalidArgument, err) - case err != nil: - // todo: move to interceptor - h.logger.Error("Relay", "error", err) - return nil, errInternal + return connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, relayer.ErrNotFound): + return connect.NewError(connect.CodeNotFound, err) + case errors.Is(err, relayer.ErrUpstream): + return connect.NewError(connect.CodeUnavailable, err) + default: + h.logger.Error(method, "err", err) + return errInternal } +} - return connect.NewResponse(&proto.RelayResponse{}), nil +// packetStateToProto maps the service's rich lifecycle state down to the coarse +// proto vocabulary. The proto has no error_ack/timed_out distinction, so both +// terminal-delivery states report as complete (the reason/effect detail is only +// carried on the HTTP wire surface). +func packetStateToProto(state relayer.PacketState) proto.TransferState { + switch state { + case relayer.StatePending: + return proto.TransferState_TRANSFER_STATE_PENDING + case relayer.StateComplete, relayer.StateTimedOut, relayer.StateErrorAck: + return proto.TransferState_TRANSFER_STATE_COMPLETE + case relayer.StateFailed: + return proto.TransferState_TRANSFER_STATE_FAILED + default: + return proto.TransferState_TRANSFER_STATE_UNKNOWN + } +} + +func txInfoToProto(info *relayer.TxInfo) *proto.TransactionInfo { + if info == nil { + return nil + } + + return &proto.TransactionInfo{TxHash: info.TxHash, ChainId: info.ChainID} } diff --git a/link/internal/server/relayer_handler_test.go b/link/internal/server/relayer_handler_test.go new file mode 100644 index 000000000..7e079830f --- /dev/null +++ b/link/internal/server/relayer_handler_test.go @@ -0,0 +1,102 @@ +package server + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/service/relayer" + proto "github.com/cosmos/ibc/link/api/v2/relayer" +) + +// fakeRelayerService is a hand-rolled RelayerService stub for handler tests. +type fakeRelayerService struct { + statuses []relayer.PacketStatus + err error +} + +func (f *fakeRelayerService) Relay(context.Context, string, string) ([]relayer.PacketStatus, error) { + return nil, f.err +} + +func (f *fakeRelayerService) Status(context.Context, string, string) ([]relayer.PacketStatus, error) { + return f.statuses, f.err +} + +// TestRelayerHandlerMapError covers the domain-error -> connect-code mapping +// once, at the mapError seam both RPCs funnel through, rather than driving every +// case through both Relay and Status. +func TestRelayerHandlerMapError(t *testing.T) { + handler := NewRelayerHandler(&fakeRelayerService{}) + + for _, tt := range []struct { + name string + err error + wantCode connect.Code + }{ + { + name: "invalid input", + err: errors.Wrap(relayer.ErrInvalidInput, "bad hash"), + wantCode: connect.CodeInvalidArgument, + }, + { + name: "not found", + err: errors.Wrap(relayer.ErrNotFound, "no packet found"), + wantCode: connect.CodeNotFound, + }, + { + name: "upstream", + err: errors.Wrap(relayer.ErrUpstream, "rpc down"), + wantCode: connect.CodeUnavailable, + }, + { + name: "internal", + err: errors.New("boom"), + wantCode: connect.CodeInternal, + }, + } { + t.Run(tt.name, func(t *testing.T) { + err := handler.mapError("Relay", tt.err) + require.Error(t, err) + assert.Equal(t, tt.wantCode, connect.CodeOf(err)) + }) + } +} + +func TestRelayerHandlerHappyPath(t *testing.T) { + ctx := context.Background() + + handler := NewRelayerHandler(&fakeRelayerService{statuses: []relayer.PacketStatus{ + { + State: relayer.StateComplete, + SequenceNumber: 7, + SourceClientID: "client-0", + SendTx: relayer.TxInfo{TxHash: "0xsend", ChainID: "1"}, + RecvTx: &relayer.TxInfo{TxHash: "0xrecv", ChainID: "2"}, + }, + }}) + + t.Run("Relay", func(t *testing.T) { + res, err := handler.Relay(ctx, connect.NewRequest(&proto.RelayRequest{ + ChainId: "1", + TxHash: "0xabc", + })) + require.NoError(t, err) + require.NotNil(t, res.Msg) + }) + + t.Run("Status", func(t *testing.T) { + res, err := handler.Status(ctx, connect.NewRequest(&proto.StatusRequest{ + ChainId: "1", + TxHash: "0xabc", + })) + require.NoError(t, err) + require.Len(t, res.Msg.PacketStatuses, 1) + assert.Equal(t, uint64(7), res.Msg.PacketStatuses[0].SequenceNumber) + assert.Equal(t, proto.TransferState_TRANSFER_STATE_COMPLETE, res.Msg.PacketStatuses[0].State) + }) +} diff --git a/link/internal/server/relayer_http.go b/link/internal/server/relayer_http.go new file mode 100644 index 000000000..a21e25bc0 --- /dev/null +++ b/link/internal/server/relayer_http.go @@ -0,0 +1,347 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/clientkey" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/relayer" + "github.com/cosmos/ibc/link/internal/service/status" +) + +// Wire endpoint paths and query parameters. The /health, /relay, and /status +// shapes mirror the e2e harness's process contract; keep them in sync with e2e +// wire/{health,relay,status}.go. /status/operator is an additive endpoint the +// harness does not vendor: it serves the rich operator view (service/status) +// and is not part of the black-box contract. +const ( + healthPath = "/health" + relayPath = "/relay" + statusPath = "/status" + operatorStatusPath = "/status/operator" + + statusQueryRoute = "route" + statusQueryPacket = "packet" + + // Operator-view query params (GET /status/operator). + statusQueryAlias = "alias" + statusQueryStuckAfter = "stuck-after" +) + +// healthPingTimeout bounds the readiness probe so a wedged database cannot hang +// the /health handler indefinitely. +const healthPingTimeout = 2 * time.Second + +// HTTPService is the relayer business logic the wire endpoints drive; the +// handler is a pure adapter over its DTOs. Satisfied by *relayer.Service. +type HTTPService interface { + // Relay records a relay request and returns the discovered packets' status. + Relay(ctx context.Context, chainID, txHash string) ([]relayer.PacketStatus, error) + // ListPacketStatuses returns the derived status of packets matching filter. + ListPacketStatuses(ctx context.Context, filter relayer.StatusFilter) ([]relayer.PacketStatus, error) + // GetPacketStatus returns the derived status of a single keyed packet. + GetPacketStatus(ctx context.Context, chainID, clientID string, sequence uint64) (relayer.PacketStatus, bool, error) +} + +// Pinger reports database reachability for the /health readiness probe. +// Satisfied by store.Store. +type Pinger interface { + Ping(ctx context.Context) error +} + +// OperatorStatusFunc assembles the rich operator status report served by +// GET /status/operator: per-chain connection state, packet counts by state, +// stuck packets with retry attempts, and per-leg attestor quorum. It is the +// same assembly the `ibc status` CLI runs; the daemon injects a closure over +// service/status.Assemble bound to its store, config, and probers. +type OperatorStatusFunc func(ctx context.Context, opts status.Options) (status.Report, error) + +// muxRegistrar mounts HTTP handlers; satisfied by *Server and *http.ServeMux. +type muxRegistrar interface { + Handle(pattern string, handler http.Handler) +} + +// HTTPHandler serves the JSON wire endpoints (/health, /relay, /status) the e2e +// harness drives. It is a thin adapter over the relayer service; no lifecycle +// logic lives here. +type HTTPHandler struct { + logger *slog.Logger + relayer HTTPService + pinger Pinger + operator OperatorStatusFunc + cfg config.Config +} + +// NewHTTPHandler constructs the wire endpoint handler. operator supplies the +// rich operator view for GET /status/operator and is required: the route is +// always mounted. +func NewHTTPHandler( + relaySvc HTTPService, + pinger Pinger, + cfg config.Config, + operator OperatorStatusFunc, +) *HTTPHandler { + return &HTTPHandler{ + logger: slog.With("handler", "relayer-http"), + relayer: relaySvc, + pinger: pinger, + operator: operator, + cfg: cfg, + } +} + +// Register mounts the wire endpoints on the given mux. +func (h *HTTPHandler) Register(mux muxRegistrar) { + mux.Handle("GET "+healthPath, http.HandlerFunc(h.handleHealth)) + mux.Handle("POST "+relayPath, http.HandlerFunc(h.handleRelay)) + mux.Handle("GET "+statusPath, http.HandlerFunc(h.handleStatus)) + mux.Handle("GET "+operatorStatusPath, http.HandlerFunc(h.handleOperatorStatus)) +} + +// handleHealth serves liveness and readiness: it succeeds only when the store +// answers a cheap Ping within a short timeout. Chain RPCs are deliberately not +// probed here so a transient upstream blip cannot flip readiness. +func (h *HTTPHandler) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), healthPingTimeout) + defer cancel() + + if err := h.pinger.Ping(ctx); err != nil { + h.logger.Error("health check failed", "err", err) + h.writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("health: store unreachable: %v", err)) + return + } + + // Success is the 200 status alone; the harness decodes no body fields. + h.writeJSON(w, http.StatusOK, struct{}{}) +} + +// handleRelay records a relay request for the source tx and reports the ids of +// the packets discovered in it. +func (h *HTTPHandler) handleRelay(w http.ResponseWriter, r *http.Request) { + var req RelayRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, fmt.Sprintf("relay: invalid request body: %v", err)) + return + } + + packets, err := h.relayer.Relay(r.Context(), req.SourceChainID, req.SourceTxHash) + switch { + case errors.Is(err, relayer.ErrNotFound): + h.writeError(w, http.StatusNotFound, fmt.Sprintf( + "relay: no packet found for sourceChainId %q sourceTxHash %q", + req.SourceChainID, req.SourceTxHash, + )) + return + case errors.Is(err, relayer.ErrInvalidInput): + h.writeError(w, http.StatusBadRequest, fmt.Sprintf( + "relay: invalid input for sourceChainId %q sourceTxHash %q: %s", + req.SourceChainID, req.SourceTxHash, err, + )) + return + case errors.Is(err, relayer.ErrUpstream): + h.writeError(w, http.StatusBadGateway, fmt.Sprintf( + "relay: discovery failed while resolving sourceChainId %q sourceTxHash %q: %s", + req.SourceChainID, req.SourceTxHash, err, + )) + return + case err != nil: + h.logger.Error("relay failed", "err", err) + h.writeError(w, http.StatusInternalServerError, fmt.Sprintf( + "relay: internal error resolving sourceChainId %q sourceTxHash %q", + req.SourceChainID, req.SourceTxHash, + )) + return + } + + ids := make([]string, 0, len(packets)) + for _, p := range packets { + ids = append(ids, fmt.Sprintf("%s-%d", h.routeID(p.SourceChainID, p.SourceClientID), p.SequenceNumber)) + } + + h.writeJSON(w, http.StatusOK, RelayResult{PacketIDs: ids}) +} + +// handleStatus reports packet statuses, optionally filtered by route or packet. +func (h *HTTPHandler) handleStatus(w http.ResponseWriter, r *http.Request) { + routeFilter := r.URL.Query().Get(statusQueryRoute) + packetFilter := r.URL.Query().Get(statusQueryPacket) + + statuses, err := h.queryStatus(r.Context(), routeFilter, packetFilter) + if err != nil { + h.logger.Error("listing packets", "err", err) + h.writeError(w, http.StatusInternalServerError, "status: internal error listing packets") + return + } + + out := make([]PacketStatus, 0, len(statuses)) + for _, s := range statuses { + out = append(out, h.toWireStatus(s)) + } + + h.writeJSON(w, http.StatusOK, StatusResponse{Packets: out}) +} + +// handleOperatorStatus serves the rich operator view: connection state per +// chain, packet counts by state, stuck packets with retry attempts, and per-leg +// attestor quorum. It is additive to the black-box /status contract and its body +// is the service/status.Report document, identical to the direct-DB `ibc status` +// CLI output. Scope with ?alias=; tune the stuck threshold with +// ?stuck-after=. +func (h *HTTPHandler) handleOperatorStatus(w http.ResponseWriter, r *http.Request) { + opts := status.Options{Alias: r.URL.Query().Get(statusQueryAlias)} + + if raw := r.URL.Query().Get(statusQueryStuckAfter); raw != "" { + d, err := time.ParseDuration(raw) + if err != nil { + h.writeError(w, http.StatusBadRequest, fmt.Sprintf( + "status: invalid %s %q: %v", statusQueryStuckAfter, raw, err)) + return + } + opts.StuckAfter = d + } + + report, err := h.operator(r.Context(), opts) + if err != nil { + h.logger.Error("assembling operator status", "err", err) + h.writeError(w, http.StatusInternalServerError, "status: internal error assembling operator report") + return + } + + h.writeJSON(w, http.StatusOK, report) +} + +// queryStatus resolves the status DTOs for a /status request, mapping route +// aliases to the service's (chainID, clientID) scope. +func (h *HTTPHandler) queryStatus( + ctx context.Context, + routeFilter, packetFilter string, +) ([]relayer.PacketStatus, error) { + // Most specific: a packet filter "-" resolves to one keyed row. + if packetFilter != "" { + s, ok, err := h.packetByFilter(ctx, packetFilter) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + // Honor a co-present route filter for parity with the old AND semantics. + if routeFilter != "" && h.routeID(s.SourceChainID, s.SourceClientID) != routeFilter { + return nil, nil + } + + return []relayer.PacketStatus{s}, nil + } + + // Route filter: scope the list to the resolved (chainID, clientID). + if routeFilter != "" { + chainID, clientID, ok := h.resolveRouteID(routeFilter) + if !ok { + return nil, nil + } + + return h.relayer.ListPacketStatuses(ctx, relayer.StatusFilter{ + SourceChainID: chainID, + SourceClientID: clientID, + }) + } + + // Unfiltered: the full list. + return h.relayer.ListPacketStatuses(ctx, relayer.StatusFilter{}) +} + +// packetByFilter resolves a "-" packet filter to a single status +// via a keyed lookup. The bool is false when the filter is malformed, its route +// is unknown, or the packet does not exist. +func (h *HTTPHandler) packetByFilter(ctx context.Context, packetFilter string) (relayer.PacketStatus, bool, error) { + sep := strings.LastIndex(packetFilter, "-") + if sep <= 0 || sep == len(packetFilter)-1 { + return relayer.PacketStatus{}, false, nil + } + + routeID := packetFilter[:sep] + seq, err := strconv.ParseUint(packetFilter[sep+1:], 10, 64) + if err != nil { + return relayer.PacketStatus{}, false, nil + } + + chainID, clientID, ok := h.resolveRouteID(routeID) + if !ok { + return relayer.PacketStatus{}, false, nil + } + + return h.relayer.GetPacketStatus(ctx, chainID, clientID, seq) +} + +// resolveRouteID reverses routeID: it maps a configured client alias, or the +// "/" fallback form, back to (chainID, clientID). +func (h *HTTPHandler) resolveRouteID(routeID string) (chainID, clientID string, ok bool) { + if client, found := h.cfg.Relayer.ClientByAlias(routeID); found { + return client.ChainID, client.ClientID, true + } + + return clientkey.Parse(routeID) +} + +// toWireStatus maps a service status DTO to its /status wire shape. +func (h *HTTPHandler) toWireStatus(s relayer.PacketStatus) PacketStatus { + routeID := h.routeID(s.SourceChainID, s.SourceClientID) + + return PacketStatus{ + PacketID: fmt.Sprintf("%s-%d", routeID, s.SequenceNumber), + RouteID: routeID, + Sequence: s.SequenceNumber, + State: wireState(s.State), + SourceTxHash: s.SendTx.TxHash, + EffectTxHash: s.EffectTxHash, + Reason: s.Reason, + } +} + +// wireState maps the service's derived lifecycle state to the wire vocabulary. +func wireState(state relayer.PacketState) PacketState { + switch state { + case relayer.StateComplete: + return PacketComplete + case relayer.StateTimedOut: + return PacketTimedOut + case relayer.StateErrorAck: + return PacketErrorAck + case relayer.StateFailed: + return PacketFailed + default: + return PacketPending + } +} + +// routeID resolves the packet's route id: the configured client alias when set, +// else "/". +func (h *HTTPHandler) routeID(sourceChainID, sourceClientID string) string { + if client, ok := h.cfg.Relayer.Client(sourceChainID, sourceClientID); ok && client.Alias != "" { + return client.Alias + } + + return clientkey.Format(sourceChainID, sourceClientID) +} + +func (h *HTTPHandler) writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + if err := json.NewEncoder(w).Encode(body); err != nil { + h.logger.Error("encoding response", "err", err) + } +} + +func (h *HTTPHandler) writeError(w http.ResponseWriter, code int, msg string) { + h.writeJSON(w, code, errorResponse{Error: msg}) +} diff --git a/link/internal/server/relayer_http_test.go b/link/internal/server/relayer_http_test.go new file mode 100644 index 000000000..416c71573 --- /dev/null +++ b/link/internal/server/relayer_http_test.go @@ -0,0 +1,448 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/relayer" + statuspkg "github.com/cosmos/ibc/link/internal/service/status" +) + +// --- fakes ----------------------------------------------------------------- + +// fakeService is a hand-rolled HTTPService stub. It records the arguments the +// handler resolved so tests can assert route-alias resolution, and returns +// canned status DTOs so tests exercise the DTO->wire mapping. +type fakeService struct { + relayPackets []relayer.PacketStatus + relayErr error + + listPackets []relayer.PacketStatus + gotFilter relayer.StatusFilter + listCalled bool + + getStatus relayer.PacketStatus + getOK bool + gotChain string + gotClient string + gotSeq uint64 +} + +func (f *fakeService) Relay(context.Context, string, string) ([]relayer.PacketStatus, error) { + return f.relayPackets, f.relayErr +} + +func (f *fakeService) ListPacketStatuses( + _ context.Context, + filter relayer.StatusFilter, +) ([]relayer.PacketStatus, error) { + f.listCalled = true + f.gotFilter = filter + + return f.listPackets, nil +} + +func (f *fakeService) GetPacketStatus( + _ context.Context, + chainID, clientID string, + sequence uint64, +) (relayer.PacketStatus, bool, error) { + f.gotChain, f.gotClient, f.gotSeq = chainID, clientID, sequence + + return f.getStatus, f.getOK, nil +} + +type fakePinger struct { + err error +} + +func (f *fakePinger) Ping(context.Context) error { return f.err } + +type fakeOperatorStatus struct { + report statuspkg.Report + err error + gotOpts statuspkg.Options + called bool +} + +func (f *fakeOperatorStatus) fn(_ context.Context, opts statuspkg.Options) (statuspkg.Report, error) { + f.called = true + f.gotOpts = opts + + return f.report, f.err +} + +// --- helpers --------------------------------------------------------------- + +func testConfig() config.Config { + return config.Config{ + Relayer: config.RelayerConfig{ + Clients: []config.ClientConfig{ + {Alias: "route-a", ChainID: "1", ClientID: "client-0"}, + }, + }, + } +} + +func newTestServer(t *testing.T, svc HTTPService, pinger Pinger) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + NewHTTPHandler(svc, pinger, testConfig(), (&fakeOperatorStatus{}).fn).Register(mux) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv +} + +func newOperatorTestServer(t *testing.T, operator OperatorStatusFunc) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + NewHTTPHandler(&fakeService{}, &fakePinger{}, testConfig(), operator).Register(mux) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv +} + +// statusDTO builds a service status DTO on the aliased route "route-a". +func statusDTO(seq uint64, state relayer.PacketState, mut func(*relayer.PacketStatus)) relayer.PacketStatus { + s := relayer.PacketStatus{ + State: state, + SequenceNumber: seq, + SourceChainID: "1", + SourceClientID: "client-0", + SendTx: relayer.TxInfo{TxHash: "0xsource", ChainID: "1"}, + } + + if mut != nil { + mut(&s) + } + + return s +} + +// --- /health --------------------------------------------------------------- + +func TestHTTP_Health_OK(t *testing.T) { + srv := newTestServer(t, &fakeService{}, &fakePinger{}) + + resp, err := http.Get(srv.URL + healthPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestHTTP_Health_Unavailable(t *testing.T) { + srv := newTestServer(t, &fakeService{}, &fakePinger{err: errors.New("db down")}) + + resp, err := http.Get(srv.URL + healthPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + + var body errorResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Contains(t, body.Error, "store unreachable") +} + +// --- /relay ---------------------------------------------------------------- + +func TestHTTP_Relay(t *testing.T) { + for _, tt := range []struct { + name string + relayErr error + packets []relayer.PacketStatus + wantStatus int + wantPacketID []string + wantContains string + }{ + { + name: "success", + packets: []relayer.PacketStatus{ + statusDTO(1, relayer.StatePending, nil), + statusDTO(2, relayer.StatePending, nil), + }, + wantStatus: http.StatusOK, + wantPacketID: []string{"route-a-1", "route-a-2"}, + }, + { + name: "not found", + relayErr: errors.Wrap(relayer.ErrNotFound, "no packet found"), + wantStatus: http.StatusNotFound, + wantContains: "no packet found", + }, + { + name: "invalid input", + relayErr: errors.Wrap(relayer.ErrInvalidInput, "bad hash"), + wantStatus: http.StatusBadRequest, + wantContains: "invalid input", + }, + { + name: "upstream", + relayErr: errors.Wrap(relayer.ErrUpstream, "rpc down"), + wantStatus: http.StatusBadGateway, + wantContains: "discovery failed", + }, + { + name: "internal", + relayErr: errors.New("boom"), + wantStatus: http.StatusInternalServerError, + wantContains: "internal error", + }, + } { + t.Run(tt.name, func(t *testing.T) { + srv := newTestServer(t, &fakeService{relayPackets: tt.packets, relayErr: tt.relayErr}, &fakePinger{}) + + resp, err := http.Post( + srv.URL+relayPath, + "application/json", + strings.NewReader(`{"sourceChainId":"1","sourceTxHash":"0xabc"}`), + ) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, tt.wantStatus, resp.StatusCode) + + if tt.wantStatus == http.StatusOK { + var result RelayResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + require.Equal(t, tt.wantPacketID, result.PacketIDs) + + return + } + + var body errorResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Contains(t, body.Error, tt.wantContains) + }) + } +} + +func TestHTTP_Relay_BadBody(t *testing.T) { + srv := newTestServer(t, &fakeService{}, &fakePinger{}) + + resp, err := http.Post(srv.URL+relayPath, "application/json", strings.NewReader(`not json`)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// --- /status --------------------------------------------------------------- + +// TestHTTP_Status_WireMapping checks the adapter maps every service state to its +// wire vocabulary, carrying the effect hash and reason through unchanged. +func TestHTTP_Status_WireMapping(t *testing.T) { + svc := &fakeService{listPackets: []relayer.PacketStatus{ + statusDTO(1, relayer.StatePending, nil), + statusDTO(2, relayer.StateComplete, func(s *relayer.PacketStatus) { s.EffectTxHash = "0xrecv2" }), + statusDTO(3, relayer.StateTimedOut, func(s *relayer.PacketStatus) { + s.EffectTxHash = "0xtimeout3" + s.Reason = "packet timed out" + }), + statusDTO(4, relayer.StateErrorAck, func(s *relayer.PacketStatus) { + s.EffectTxHash = "0xack4" + s.Reason = "error ack" + }), + statusDTO(5, relayer.StateFailed, func(s *relayer.PacketStatus) { s.Reason = "failed" }), + }} + srv := newTestServer(t, svc, &fakePinger{}) + + status := getStatus(t, srv.URL+statusPath) + require.Len(t, status.Packets, 5) + + byID := map[string]PacketStatus{} + for _, p := range status.Packets { + byID[p.PacketID] = p + } + + require.Equal(t, PacketPending, byID["route-a-1"].State) + require.Equal(t, "route-a", byID["route-a-1"].RouteID) + require.Equal(t, "0xsource", byID["route-a-1"].SourceTxHash) + + require.Equal(t, PacketComplete, byID["route-a-2"].State) + require.Equal(t, "0xrecv2", byID["route-a-2"].EffectTxHash) + + require.Equal(t, PacketTimedOut, byID["route-a-3"].State) + require.Equal(t, "0xtimeout3", byID["route-a-3"].EffectTxHash) + require.Equal(t, "packet timed out", byID["route-a-3"].Reason) + + require.Equal(t, PacketErrorAck, byID["route-a-4"].State) + require.Equal(t, "0xack4", byID["route-a-4"].EffectTxHash) + + require.Equal(t, PacketFailed, byID["route-a-5"].State) + require.Empty(t, byID["route-a-5"].EffectTxHash) +} + +func TestHTTP_Status_RouteFilterResolvesAlias(t *testing.T) { + svc := &fakeService{listPackets: []relayer.PacketStatus{statusDTO(1, relayer.StatePending, nil)}} + srv := newTestServer(t, svc, &fakePinger{}) + + status := getStatus(t, srv.URL+statusPath+"?"+statusQueryRoute+"=route-a") + require.Len(t, status.Packets, 1) + require.Equal(t, "route-a-1", status.Packets[0].PacketID) + + // The alias resolved to the configured (chainID, clientID) scope. + require.Equal(t, relayer.StatusFilter{SourceChainID: "1", SourceClientID: "client-0"}, svc.gotFilter) +} + +func TestHTTP_Status_UnknownRouteIsEmpty(t *testing.T) { + svc := &fakeService{} + srv := newTestServer(t, svc, &fakePinger{}) + + status := getStatus(t, srv.URL+statusPath+"?"+statusQueryRoute+"=no-such-route") + require.Empty(t, status.Packets) + require.False(t, svc.listCalled, "service must not be queried for an unresolvable route") +} + +func TestHTTP_Status_PacketFilterResolvesKey(t *testing.T) { + svc := &fakeService{getOK: true, getStatus: statusDTO(3, relayer.StateTimedOut, func(s *relayer.PacketStatus) { + s.EffectTxHash = "0xtimeout3" + })} + srv := newTestServer(t, svc, &fakePinger{}) + + status := getStatus(t, srv.URL+statusPath+"?"+statusQueryPacket+"=route-a-3") + require.Len(t, status.Packets, 1) + require.Equal(t, "route-a-3", status.Packets[0].PacketID) + require.Equal(t, PacketTimedOut, status.Packets[0].State) + + // The "-" filter resolved to the keyed lookup args. + require.Equal(t, "1", svc.gotChain) + require.Equal(t, "client-0", svc.gotClient) + require.Equal(t, uint64(3), svc.gotSeq) +} + +func TestHTTP_Status_PacketFilterUnknownIsEmpty(t *testing.T) { + svc := &fakeService{getOK: false} + srv := newTestServer(t, svc, &fakePinger{}) + + status := getStatus(t, srv.URL+statusPath+"?"+statusQueryPacket+"=route-a-999") + require.Empty(t, status.Packets) +} + +func TestHTTP_Status_EmptyIsEmptyArray(t *testing.T) { + srv := newTestServer(t, &fakeService{listPackets: nil}, &fakePinger{}) + + resp, err := http.Get(srv.URL + statusPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + var raw map[string]json.RawMessage + require.NoError(t, json.NewDecoder(resp.Body).Decode(&raw)) + require.JSONEq(t, `[]`, string(raw["packets"])) +} + +func getStatus(t *testing.T, url string) StatusResponse { + t.Helper() + + resp, err := http.Get(url) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var status StatusResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&status)) + + return status +} + +// --- /status/operator ------------------------------------------------------ + +func TestHTTP_OperatorStatus_OK(t *testing.T) { + reporter := &fakeOperatorStatus{report: statuspkg.Report{ + StuckAfter: "10m0s", + Routes: []statuspkg.RouteStatus{{ + Alias: "route-a", + ChainID: "1", + ClientID: "client-0", + Connection: statuspkg.ConnectionStatus{Source: statuspkg.EndpointStatus{State: statuspkg.EndpointReachable}}, + Packets: statuspkg.PacketsStatus{Total: 2, ByState: map[string]int{"pending": 2}}, + }}, + }} + srv := newOperatorTestServer(t, reporter.fn) + + resp, err := http.Get(srv.URL + operatorStatusPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var report statuspkg.Report + require.NoError(t, json.NewDecoder(resp.Body).Decode(&report)) + require.Equal(t, "10m0s", report.StuckAfter) + require.Len(t, report.Routes, 1) + require.Equal(t, "route-a", report.Routes[0].Alias) + require.Equal(t, statuspkg.EndpointReachable, report.Routes[0].Connection.Source.State) + require.Equal(t, 2, report.Routes[0].Packets.Total) + + require.True(t, reporter.called) + require.Equal(t, statuspkg.Options{}, reporter.gotOpts) +} + +func TestHTTP_OperatorStatus_QueryParams(t *testing.T) { + reporter := &fakeOperatorStatus{} + srv := newOperatorTestServer(t, reporter.fn) + + resp, err := http.Get(srv.URL + operatorStatusPath + "?alias=route-a&stuck-after=5m") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "route-a", reporter.gotOpts.Alias) + require.Equal(t, 5*time.Minute, reporter.gotOpts.StuckAfter) +} + +func TestHTTP_OperatorStatus_BadStuckAfter(t *testing.T) { + reporter := &fakeOperatorStatus{} + srv := newOperatorTestServer(t, reporter.fn) + + resp, err := http.Get(srv.URL + operatorStatusPath + "?stuck-after=not-a-duration") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.False(t, reporter.called) + + var body errorResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Contains(t, body.Error, "stuck-after") +} + +func TestHTTP_OperatorStatus_ReporterError(t *testing.T) { + srv := newOperatorTestServer(t, (&fakeOperatorStatus{err: errors.New("db down")}).fn) + + resp, err := http.Get(srv.URL + operatorStatusPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +// --- id mapping ------------------------------------------------------------ + +func TestHTTP_RouteID(t *testing.T) { + h := NewHTTPHandler(&fakeService{}, &fakePinger{}, testConfig(), (&fakeOperatorStatus{}).fn) + + // alias present -> the configured alias + require.Equal(t, "route-a", h.routeID("1", "client-0")) + + // alias absent -> "/" + require.Equal(t, "1/unknown-0", h.routeID("1", "unknown-0")) + require.Equal(t, "9/client-0", h.routeID("9", "client-0")) +} diff --git a/link/internal/server/server.go b/link/internal/server/server.go index 0ff213918..d3070fda7 100644 --- a/link/internal/server/server.go +++ b/link/internal/server/server.go @@ -14,10 +14,12 @@ import ( // Server wraps the HTTP server and registered RPC handlers. type Server struct { - mux *http.ServeMux - server *http.Server - logger *slog.Logger + mux *http.ServeMux + server *http.Server + listener net.Listener + logger *slog.Logger + handlerOpts []connect.HandlerOption useReflection bool serviceNames []string reflectionRegistered bool @@ -31,8 +33,9 @@ type Handler interface { var errInternal = connect.NewError(connect.CodeInternal, errors.New("internal server error")) -// New Server constructor. -func New(addr string, useReflection bool) *Server { +// New Server constructor. handlerOpts (e.g. interceptors) are applied to every +// RPC handler the server mounts. +func New(addr string, useReflection bool, handlerOpts ...connect.HandlerOption) *Server { // Use h2c so we can serve HTTP/2 without TLS. // https://connectrpc.com/docs/go/deployment/#h2c // todo: revisit in future @@ -44,6 +47,7 @@ func New(addr string, useReflection bool) *Server { return &Server{ mux: mux, + handlerOpts: handlerOpts, useReflection: useReflection, server: &http.Server{ Addr: addr, @@ -54,7 +58,9 @@ func New(addr string, useReflection bool) *Server { } } -// Start starts the server. Not safe to call twice. +// Start binds the listener and begins serving. The listener is bound +// synchronously so Addr() is valid as soon as Start returns; serving happens on +// a background goroutine. Not safe to call twice. func (s *Server) Start() error { s.setupReflection() @@ -65,13 +71,26 @@ func (s *Server) Start() error { return err } + s.listener = ln + go s.start(ln) return nil } +// Addr returns the actual bound listen address (host:port). It is only valid +// after Start has returned. When the config requests an ephemeral port +// (host:0), this reports the concrete port the OS assigned. +func (s *Server) Addr() string { + if s.listener == nil { + return s.server.Addr + } + + return s.listener.Addr().String() +} + func (s *Server) Stop() error { - const timeout = 3 * time.Second + const timeout = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -82,14 +101,25 @@ func (s *Server) Stop() error { } func (s *Server) Register(h Handler) { - // we might want to pass some global options here later - prefix, handler := h.Register() + prefix, handler := h.Register(s.handlerOpts...) s.logger.Debug("Registered handler", "prefix", prefix) s.mux.Handle(prefix, handler) s.serviceNames = append(s.serviceNames, h.Name()) } +// ServiceNames returns the fully-qualified names of the registered RPC +// services, in registration order. Used to seed the gRPC health service. +func (s *Server) ServiceNames() []string { + return s.serviceNames +} + +// Handle mounts a plain HTTP handler on the same mux as the ConnectRPC +// handlers. Used for the JSON wire endpoints (/health, /relay, /status). +func (s *Server) Handle(pattern string, handler http.Handler) { + s.mux.Handle(pattern, handler) +} + func (s *Server) start(ln net.Listener) { err := s.server.Serve(ln) switch err { diff --git a/link/internal/server/server_test.go b/link/internal/server/server_test.go new file mode 100644 index 000000000..f897e9cfb --- /dev/null +++ b/link/internal/server/server_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "net" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" +) + +// TestServer_EphemeralPort verifies the server binds an OS-assigned port when +// configured with host:0, exposes it via Addr(), serves /health, and stops. +func TestServer_EphemeralPort(t *testing.T) { + srv := New("127.0.0.1:0", false) + NewHTTPHandler(&fakeService{}, &fakePinger{}, config.Config{}, (&fakeOperatorStatus{}).fn).Register(srv) + + require.NoError(t, srv.Start()) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + + addr := srv.Addr() + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + require.Equal(t, "127.0.0.1", host) + + portNum, err := strconv.Atoi(port) + require.NoError(t, err) + require.NotZero(t, portNum, "ephemeral port must be resolved to a concrete value") + + resp, err := http.Get("http://" + addr + healthPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/link/internal/server/status_regression_test.go b/link/internal/server/status_regression_test.go new file mode 100644 index 000000000..191babeca --- /dev/null +++ b/link/internal/server/status_regression_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/service/relayer" + "github.com/cosmos/ibc/link/internal/store" +) + +// stubChainManager satisfies relayer.ChainClientManager. The /status list and +// keyed paths never resolve a chain client, so GetClient is never called. +type stubChainManager struct{} + +func (stubChainManager) GetClient(string) (chains.Client, error) { + return nil, errors.New("stub: GetClient must not be called on the status path") +} + +// statusRegressionPackets seeds one packet per meaningfully-distinct wire +// state (pending, complete, timed_out, failed), including +// store.RelayStatusFailedInvariant. +var statusRegressionPackets = []store.RelayStatus{ + store.RelayStatusPending, // in-flight -> wire "pending" + store.RelayStatusCompleteWithAck, // terminal -> wire "complete" + store.RelayStatusCompleteWithTimeout, // terminal -> wire "timed_out" + store.RelayStatusFailedInvariant, // terminal -> wire "failed" +} + +// TestStatusEndpoint_SmokeAcrossRepresentativeStates drives the real /status +// HTTP handler, backed by a real relayer.Service over a real sqlite store, +// with one packet per meaningfully-distinct wire state. The per-layer mapping +// (store list -> service derivation -> handler wiring) is covered by the +// store and service/status packages' own tests; this test only guarantees the +// three layers wire together: the unfiltered list-all, the route-filtered +// list, and a keyed lookup must all return 200 with the expected packets. +func TestStatusEndpoint_SmokeAcrossRepresentativeStates(t *testing.T) { + ctx := context.Background() + + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + _, err = db.MigrateUp() + require.NoError(t, err) + + cfg := testConfig() // client {Alias: "route-a", ChainID: "1", ClientID: "client-0"} + const chainID, clientID, routeID = "1", "client-0", "route-a" + txTime := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + + for i, status := range statusRegressionPackets { + seq := uint64(i + 1) + require.NoError(t, db.CreatePacket(ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainID, + DestinationChainID: "8453", + SourceTxHash: "0xsource", + SourceTxTime: txTime, + PacketSequenceNumber: seq, + PacketSourceClientID: clientID, + PacketDestinationClientID: "client-1", + PacketTimeoutTimestamp: txTime.Add(time.Hour), + })) + key := store.PacketKey{SourceChainID: chainID, PacketSequenceNumber: seq, PacketSourceClientID: clientID} + require.NoError(t, db.SetPacketRecvTx(ctx, key, "0xrecv", txTime, "relayer")) + require.NoError(t, db.SetPacketWriteAckTx(ctx, key, "0xwriteack", txTime, store.WriteAckStatusSuccess)) + require.NoError(t, db.SetPacketAckTx(ctx, key, "0xack", txTime, "relayer")) + require.NoError(t, db.SetPacketTimeoutTx(ctx, key, "0xtimeout", txTime, "relayer")) + require.NoError(t, db.UpdatePacketStatus(ctx, key, status)) + } + + svc := relayer.New(cfg, db, stubChainManager{}) + mux := http.NewServeMux() + NewHTTPHandler(svc, db, cfg, (&fakeOperatorStatus{}).fn).Register(mux) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + want := len(statusRegressionPackets) + + // Unfiltered list-all: 200 (asserted by getStatus), every packet present. + all := getStatus(t, srv.URL+statusPath) + assert.Len(t, all.Packets, want) + + // Route-filtered list: 200, every packet present (all share one route). + byRoute := getStatus(t, srv.URL+statusPath+"?route="+routeID) + assert.Len(t, byRoute.Packets, want) + + // Keyed lookup for one seeded packet: 200, exactly that packet. + packetID := fmt.Sprintf("%s-%d", routeID, 1) + one := getStatus(t, srv.URL+statusPath+"?packet="+packetID) + require.Len(t, one.Packets, 1, "keyed lookup for %s", packetID) + assert.Equal(t, packetID, one.Packets[0].PacketID) +} diff --git a/link/internal/server/wire.go b/link/internal/server/wire.go new file mode 100644 index 000000000..9b1e63bfc --- /dev/null +++ b/link/internal/server/wire.go @@ -0,0 +1,74 @@ +package server + +// This file mirrors the JSON wire contract that the e2e harness expects from +// `ibc relayer run` (readiness line + /relay, /status, /health responses). The +// shapes are duplicated here on purpose: the harness owns the canonical types in +// its own module and link must not import across the module boundary. + +// ReadinessEvent is the fixed value of the readiness line's "event" field. +const ReadinessEvent = "ready" + +// Readiness is the single JSON line printed to stdout once the daemon is ready. +type Readiness struct { + Event string `json:"event"` + ConfigLoaded bool `json:"configLoaded"` + DBReady bool `json:"dbReady"` + ChainsConnected []string `json:"chainsConnected"` + RelayerSubscribed bool `json:"relayerSubscribed"` + Status ReadinessStatus `json:"status"` +} + +// ReadinessStatus carries the actual bound HTTP listen address. +type ReadinessStatus struct { + HTTP string `json:"http"` +} + +// RelayRequest is the POST /relay request body. +type RelayRequest struct { + SourceChainID string `json:"sourceChainId"` + SourceTxHash string `json:"sourceTxHash"` +} + +// RelayResult is the POST /relay success response body. +type RelayResult struct { + PacketIDs []string `json:"packetIds"` +} + +// PacketState is the coarse relay state reported by /status. It is the only +// vocabulary the harness understands. +type PacketState string + +// Packet states. +const ( + PacketPending PacketState = "pending" + PacketComplete PacketState = "complete" + PacketTimedOut PacketState = "timed_out" + PacketErrorAck PacketState = "error_ack" + // PacketFailed marks a packet whose relay failed permanently. It is distinct + // from error_ack (which means the destination app rejected a delivered + // packet) and carries no fabricated effect tx hash. + PacketFailed PacketState = "failed" +) + +// StatusResponse is the GET /status response body. +type StatusResponse struct { + Packets []PacketStatus `json:"packets"` +} + +// PacketStatus is a single packet's status in the /status response. Terminal +// states (timed_out, error_ack) carry a non-empty EffectTxHash and Reason. +type PacketStatus struct { + PacketID string `json:"packetId"` + RouteID string `json:"routeId"` + Sequence uint64 `json:"sequence"` + State PacketState `json:"state"` + SourceTxHash string `json:"sourceTxHash"` + EffectTxHash string `json:"effectTxHash,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// errorResponse is the JSON body returned for non-200 responses. The harness +// surfaces the raw body text, so callers only rely on substrings within Error. +type errorResponse struct { + Error string `json:"error"` +} diff --git a/link/internal/server/wire_test.go b/link/internal/server/wire_test.go new file mode 100644 index 000000000..284530f0a --- /dev/null +++ b/link/internal/server/wire_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadiness_JSONShape(t *testing.T) { + r := Readiness{ + Event: ReadinessEvent, + ConfigLoaded: true, + DBReady: true, + ChainsConnected: []string{"1", "2"}, + RelayerSubscribed: true, + Status: ReadinessStatus{HTTP: "127.0.0.1:8080"}, + } + + bz, err := json.Marshal(r) + require.NoError(t, err) + + // The harness parses this line with json.Unmarshal, so key ordering is + // irrelevant; assert the field names and values via an order-insensitive + // decode rather than a byte-exact string. + var got map[string]any + require.NoError(t, json.Unmarshal(bz, &got)) + + require.Equal(t, map[string]any{ + "event": "ready", + "configLoaded": true, + "dbReady": true, + "chainsConnected": []any{"1", "2"}, + "relayerSubscribed": true, + "status": map[string]any{"http": "127.0.0.1:8080"}, + }, got) +} diff --git a/link/internal/service/attestor/adapter.go b/link/internal/service/attestor/adapter.go new file mode 100644 index 000000000..1ebe2cf58 --- /dev/null +++ b/link/internal/service/attestor/adapter.go @@ -0,0 +1,31 @@ +package attestor + +import ( + "context" + "time" +) + +// ChainAdapter abstracts the chain-specific queries a LocalAttestor needs. EVM is +// the only implementation today; the interface keeps the attestor decoupled from +// the chains package so future chain types can be added. +// +// All queries MUST be pinned at the requested height so a load-balanced RPC cluster +// cannot serve a mix of heights within a single attestation. +type ChainAdapter interface { + // FinalizedHeight returns the latest attestable height, applying the attestor's + // configured finality offset (latest - offset, saturating) or the chain's native + // "finalized" tag when no offset is configured. + FinalizedHeight(ctx context.Context) (uint64, error) + + // HeaderTimestamp returns the block timestamp at the given height. + HeaderTimestamp(ctx context.Context, height uint64) (time.Time, error) + + // GetCommitment reads router.getCommitment(hashedPath) via eth_call pinned at + // height. A zero result means the value is absent. + GetCommitment(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error) + + // Close releases the adapter's underlying chain transport (a dialed RPC + // connection for the EVM adapter). It is safe to call once and tolerates + // adapters with no closable transport. + Close() error +} diff --git a/link/internal/service/attestor/adapter_evm.go b/link/internal/service/attestor/adapter_evm.go new file mode 100644 index 000000000..c1077c49d --- /dev/null +++ b/link/internal/service/attestor/adapter_evm.go @@ -0,0 +1,83 @@ +package attestor + +import ( + "context" + "time" + + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/config" +) + +// chainIDProbeTimeout bounds the startup eth_chainId identity probe so an +// unreachable RPC cannot stall attestor construction indefinitely. +const chainIDProbeTimeout = 10 * time.Second + +// NewChainAdapter builds a ChainAdapter for an attestation config entry. +func NewChainAdapter(chain config.ChainConfig, att config.AttestationConfig) (ChainAdapter, error) { + if chain.Type() != config.ChainTypeEVM { + return nil, errors.Errorf( + "unsupported chain type %q for attestation %q (chainId %q)", + chain.Type(), att.Name, att.ChainID, + ) + } + + if att.EVM == nil { + return nil, errors.Errorf("attestation %q (chainId %q) requires an evm config block", att.Name, att.ChainID) + } + + // routerAddress is optional in the config schema (greenfield combined configs + // omit it), so enforce non-emptiness here where it is actually consumed. + if att.EVM.RouterAddress == "" { + return nil, errors.Errorf( + "attestation %q has no evm.routerAddress; run `ibc connect --write-config` "+ + "or set attestor.attestations[].evm.routerAddress", att.Name) + } + + client, err := evm.New(chain.ChainID, chain.EVM.RPC, att.EVM.RouterAddress) + if err != nil { + return nil, errors.Wrapf(err, "creating evm client for attestation %q", att.Name) + } + + // Attestation signatures carry no chain domain (see attestation.SigningInput), + // so this eth_chainId identity check at construction is the only defense against + // signing another chain's state from a misconfigured RPC. Numeric configured ids + // must match the node's reported id; non-numeric label ids are exempt (identical + // to the relayer's policy, via the shared evm helper). + ctx, cancel := context.WithTimeout(context.Background(), chainIDProbeTimeout) + defer cancel() + nodeChainID, err := client.ChainID(ctx) + if err == nil { + err = evm.CheckChainID(chain.ChainID, nodeChainID) + } + if err != nil { + _ = client.Close() + return nil, errors.Wrapf(err, "verifying chain id for attestation %q (chainId %q)", att.Name, att.ChainID) + } + + return &evmChainAdapter{client: client, finalityOffset: att.EVM.FinalityOffset}, nil +} + +// evmChainAdapter adapts the EVM chains client to the attestor's ChainAdapter. +type evmChainAdapter struct { + client *evm.Client + finalityOffset *uint64 +} + +func (a *evmChainAdapter) FinalizedHeight(ctx context.Context) (uint64, error) { + return a.client.FinalizedHeight(ctx, a.finalityOffset) +} + +func (a *evmChainAdapter) HeaderTimestamp(ctx context.Context, height uint64) (time.Time, error) { + return a.client.HeaderTimestamp(ctx, height) +} + +func (a *evmChainAdapter) GetCommitment(ctx context.Context, hashedPath [32]byte, height uint64) ([32]byte, error) { + return a.client.GetCommitment(ctx, hashedPath, height) +} + +// Close releases the dialed EVM RPC connection owned by this adapter. +func (a *evmChainAdapter) Close() error { + return a.client.Close() +} diff --git a/link/internal/service/attestor/adapter_evm_test.go b/link/internal/service/attestor/adapter_evm_test.go new file mode 100644 index 000000000..5c1c909fe --- /dev/null +++ b/link/internal/service/attestor/adapter_evm_test.go @@ -0,0 +1,106 @@ +package attestor + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains/evm" + "github.com/cosmos/ibc/link/internal/config" +) + +const adapterRouter = "0x1111111111111111111111111111111111111111" + +// chainIDRPCServer stands in for an EVM node that answers eth_chainId with a +// fixed numeric id, so NewChainAdapter's identity probe can run without a live +// chain. +func chainIDRPCServer(t *testing.T, chainID int64) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + // The handler runs on a non-test goroutine, where FailNow is invalid: + // report via t.Error and answer 400 instead. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decoding rpc request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + result := "null" + if req.Method == "eth_chainId" { + result = fmt.Sprintf("%q", fmt.Sprintf("0x%x", chainID)) + } + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%s}`, req.ID, result) + })) + t.Cleanup(srv.Close) + + return srv +} + +func adapterConfigs(t *testing.T, configuredChainID, rpcURL string) (config.ChainConfig, config.AttestationConfig) { + t.Helper() + + chain := config.ChainConfig{ + ChainID: configuredChainID, + EVM: &config.EVMChainConfig{RPC: rpcURL, ICS26Router: adapterRouter}, + } + att := config.AttestationConfig{ + ChainID: configuredChainID, + Name: "attestation-a", + Signer: "signer-a", + EVM: &config.AttestationEVMConfig{RouterAddress: adapterRouter}, + } + + return chain, att +} + +// TestNewChainAdapterVerifiesChainID proves the standalone attestor now fails +// construction when a numeric configured chain id disagrees with the node's +// eth_chainId — the only config-time defense against signing another chain's +// state (attestation signatures carry no chain domain). +func TestNewChainAdapterVerifiesChainID(t *testing.T) { + t.Run("matchSucceeds", func(t *testing.T) { + srv := chainIDRPCServer(t, 8453) + chain, att := adapterConfigs(t, "8453", srv.URL) + + adapter, err := NewChainAdapter(chain, att) + require.NoError(t, err) + require.NotNil(t, adapter) + require.NoError(t, adapter.Close()) + }) + + t.Run("mismatchFails", func(t *testing.T) { + srv := chainIDRPCServer(t, 999) + chain, att := adapterConfigs(t, "8453", srv.URL) + + adapter, err := NewChainAdapter(chain, att) + require.Error(t, err) + assert.Nil(t, adapter) + + var mismatch *evm.ChainIDMismatchError + require.ErrorAs(t, err, &mismatch) + assert.Equal(t, "8453", mismatch.Configured) + assert.Equal(t, "999", mismatch.Reported) + }) + + t.Run("nonNumericLabelExempt", func(t *testing.T) { + // A label configured id is exempt from the numeric identity check. + srv := chainIDRPCServer(t, 31337) + chain, att := adapterConfigs(t, "devnet-local", srv.URL) + + adapter, err := NewChainAdapter(chain, att) + require.NoError(t, err) + require.NotNil(t, adapter) + require.NoError(t, adapter.Close()) + }) +} diff --git a/link/internal/service/attestor/fake_adapter_test.go b/link/internal/service/attestor/fake_adapter_test.go new file mode 100644 index 000000000..e9ea631e8 --- /dev/null +++ b/link/internal/service/attestor/fake_adapter_test.go @@ -0,0 +1,51 @@ +package attestor + +import ( + "context" + "sync/atomic" + "time" +) + +// fakeAdapter is a hand-rolled ChainAdapter for tests. +type fakeAdapter struct { + finalizedHeight uint64 + finalizedErr error + + timestamp time.Time + timestampErr error + + // commitments is keyed by hashedPath; missing keys return zero (absent). + commitments map[[32]byte][32]byte + commitmentErr error + + // getCommitmentCalls counts GetCommitment invocations (concurrency assertions). + getCommitmentCalls atomic.Int64 + + // closed records Close calls; closeErr is returned by Close when set. + closed atomic.Bool + closeErr error +} + +var _ ChainAdapter = (*fakeAdapter)(nil) + +func (f *fakeAdapter) FinalizedHeight(context.Context) (uint64, error) { + return f.finalizedHeight, f.finalizedErr +} + +func (f *fakeAdapter) HeaderTimestamp(context.Context, uint64) (time.Time, error) { + return f.timestamp, f.timestampErr +} + +func (f *fakeAdapter) GetCommitment(_ context.Context, hashedPath [32]byte, _ uint64) ([32]byte, error) { + f.getCommitmentCalls.Add(1) + if f.commitmentErr != nil { + return zero32, f.commitmentErr + } + return f.commitments[hashedPath], nil +} + +// closed records whether Close was called (leak assertions). +func (f *fakeAdapter) Close() error { + f.closed.Store(true) + return f.closeErr +} diff --git a/link/internal/service/attestor/local.go b/link/internal/service/attestor/local.go index b97415f08..f089f323f 100644 --- a/link/internal/service/attestor/local.go +++ b/link/internal/service/attestor/local.go @@ -4,22 +4,30 @@ import ( "context" "fmt" "log/slog" - "time" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" "github.com/cosmos/ibc/link/internal/service/signer" ) -// LocalAttestor provides attestation data from the local process. +var zero32 [32]byte + +// LocalAttestor provides attestation data from the local process by querying a +// chain adapter and signing with a local (or KMS-backed) signer. type LocalAttestor struct { chainID string name string signer signer.Signer + adapter ChainAdapter logger *slog.Logger } var _ Attestor = &LocalAttestor{} -func NewLocal(chainID, name string, backingSigner signer.Signer) (*LocalAttestor, error) { +func NewLocal(chainID, name string, backingSigner signer.Signer, adapter ChainAdapter) (*LocalAttestor, error) { switch { case chainID == "": return nil, fmt.Errorf("chainID required") @@ -29,6 +37,8 @@ func NewLocal(chainID, name string, backingSigner signer.Signer) (*LocalAttestor return nil, fmt.Errorf("signer required") case backingSigner.Type() != signer.ECDSA: return nil, fmt.Errorf("ECDSA signer required, got %s", backingSigner.Type()) + case adapter == nil: + return nil, fmt.Errorf("chain adapter required") } logger := slog.With("module", "attestor", "name", attestorFQN("local", chainID, name)) @@ -37,20 +47,206 @@ func NewLocal(chainID, name string, backingSigner signer.Signer) (*LocalAttestor chainID: chainID, name: name, signer: backingSigner, + adapter: adapter, logger: logger, }, nil } -func (a *LocalAttestor) LatestAttestableHeight(_ context.Context) (uint64, error) { - // todo: mocked - return uint64(time.Now().Unix()), nil +func (a *LocalAttestor) LatestAttestableHeight(ctx context.Context) (uint64, error) { + return a.adapter.FinalizedHeight(ctx) +} + +// StateAttestation produces a signed attestation over StateAttestation{height, timestamp}. +func (a *LocalAttestor) StateAttestation(ctx context.Context, height uint64) (*Attestation, error) { + if err := a.validateFinalized(ctx, height); err != nil { + return nil, err + } + + timestamp, err := a.adapter.HeaderTimestamp(ctx, height) + if err != nil { + return nil, errors.Wrap(err, "header timestamp") + } + + unixSeconds := uint64(timestamp.Unix()) + attestedData := attestation.EncodeStateAttestation(height, unixSeconds) + + sig, err := a.sign(ctx, attestation.TypeState, attestedData) + if err != nil { + return nil, err + } + + return &Attestation{ + Height: height, + Timestamp: &unixSeconds, + AttestedData: attestedData, + Signature: sig, + }, nil +} + +// PacketAttestation produces a signed attestation over PacketAttestation{height, packets}. +// All packets must succeed or the whole request fails. +func (a *LocalAttestor) PacketAttestation( + ctx context.Context, + encodedPackets [][]byte, + height uint64, + proofType ProofType, +) (*Attestation, error) { + // Bound check FIRST, before any decoding or chain calls. + switch n := len(encodedPackets); { + case n == 0: + return nil, errors.Wrap(ErrInvalidInput, "packet attestation request contains no packets") + case n > MaxPacketsPerAttestation: + return nil, errors.Wrapf(ErrInvalidInput, + "packet attestation request contains %d packets, exceeding maximum of %d", + n, MaxPacketsPerAttestation) + } + + packets := make([]chains.Packet, len(encodedPackets)) + for i, raw := range encodedPackets { + if len(raw) == 0 { + return nil, errors.Wrapf(ErrInvalidInput, "packet[%d] is empty", i) + } + + pkt, err := attestation.DecodePacket(raw) + if err != nil { + return nil, errors.Wrapf(ErrInvalidInput, "decode packet[%d]: %s", i, err) + } + packets[i] = pkt + } + + if err := a.validateFinalized(ctx, height); err != nil { + return nil, err + } + + compacts := make([]attestation.PacketCompact, len(packets)) + group, groupCtx := errgroup.WithContext(ctx) + for i := range packets { + group.Go(func() error { + compact, err := a.packetCompact(groupCtx, packets[i], height, proofType) + if err != nil { + return err + } + compacts[i] = compact // index-scoped write, no lock needed + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + + attestedData, err := attestation.EncodePacketAttestation(height, compacts) + if err != nil { + return nil, errors.Wrap(err, "encode packet attestation") + } + + sig, err := a.sign(ctx, attestation.TypePacket, attestedData) + if err != nil { + return nil, err + } + + return &Attestation{ + Height: height, + Timestamp: nil, // absent for packet attestations + AttestedData: attestedData, + Signature: sig, + }, nil +} + +// packetCompact derives the PacketCompact for a single packet under the given proof type. +func (a *LocalAttestor) packetCompact( + ctx context.Context, + pkt chains.Packet, + height uint64, + proofType ProofType, +) (attestation.PacketCompact, error) { + var ( + rawPath []byte + commitment [32]byte + ) + + switch proofType { + case ProofTypePacketCommitment: + rawPath = attestation.PacketCommitmentPath(pkt.SourceClient, pkt.Sequence) + chainVal, err := a.adapter.GetCommitment(ctx, attestation.PathHash(rawPath), height) + if err != nil { + return attestation.PacketCompact{}, errors.Wrap(err, "get commitment") + } + if chainVal == zero32 { + return attestation.PacketCompact{}, errors.Wrapf(ErrCommitmentNotFound, + "commitment not found client_id=%s sequence=%d at height=%d", + pkt.SourceClient, pkt.Sequence, height) + } + if expected := attestation.PacketCommitment(pkt); chainVal != expected { + return attestation.PacketCompact{}, errors.Wrapf(ErrInvalidInput, + "packet commitment found but invalid due to: on-chain %x != expected %x", + chainVal, expected) + } + commitment = chainVal + + case ProofTypePacketAcknowledgement: + rawPath = attestation.PacketAckCommitmentPath(pkt.DestClient, pkt.Sequence) + chainVal, err := a.adapter.GetCommitment(ctx, attestation.PathHash(rawPath), height) + if err != nil { + return attestation.PacketCompact{}, errors.Wrap(err, "get commitment") + } + if chainVal == zero32 { + return attestation.PacketCompact{}, errors.Wrapf(ErrCommitmentNotFound, + "commitment not found client_id=%s sequence=%d at height=%d", + pkt.DestClient, pkt.Sequence, height) + } + commitment = chainVal // used verbatim, no recompute + + case ProofTypePacketReceiptAbsence: + rawPath = attestation.PacketReceiptPath(pkt.DestClient, pkt.Sequence) + chainVal, err := a.adapter.GetCommitment(ctx, attestation.PathHash(rawPath), height) + if err != nil { + return attestation.PacketCompact{}, errors.Wrap(err, "get commitment") + } + if chainVal != zero32 { + return attestation.PacketCompact{}, errors.Wrapf(ErrInvalidInput, + "receipt must be absent but found for client_id=%s sequence=%d at height=%d", + pkt.DestClient, pkt.Sequence, height) + } + commitment = zero32 + + default: + return attestation.PacketCompact{}, errors.Wrapf(ErrInvalidInput, "unknown proof type %d", proofType) + } + + return attestation.PacketCompact{ + Path: attestation.PathHash(rawPath), + Commitment: commitment, + }, nil +} + +// validateFinalized rejects heights above the chain's finalized height. +func (a *LocalAttestor) validateFinalized(ctx context.Context, height uint64) error { + finalized, err := a.adapter.FinalizedHeight(ctx) + if err != nil { + return errors.Wrap(err, "finalized height") + } + if height > finalized { + return errors.Wrapf(ErrNotFinalized, "block is not finalized: height=%d finalized=%d", height, finalized) + } + return nil +} + +// sign hashes and signs attestedData with the type tag and normalizes the recovery id. +func (a *LocalAttestor) sign(ctx context.Context, tag byte, attestedData []byte) ([]byte, error) { + raw, err := a.signer.Sign(ctx, attestation.SigningInput(tag, attestedData)) + if err != nil { + return nil, errors.Wrap(err, "sign") + } + sig, err := attestation.NormalizeV(raw) + if err != nil { + return nil, errors.Wrap(err, "normalize signature") + } + return sig, nil } -// name and alias are identical for local attestors -func (a *LocalAttestor) Name() string { return a.name } -func (a *LocalAttestor) Alias() string { return a.name } -func (a *LocalAttestor) ChainID() string { return a.chainID } -func (a *LocalAttestor) IsLocal() bool { return true } +// Alias is the attestor's unique name within this process; local attestors use +// the configured attestor name. +func (a *LocalAttestor) Alias() string { return a.name } func attestorFQN(connection, chainID, name string) string { return fmt.Sprintf("%s-%s-%s", chainID, connection, name) diff --git a/link/internal/service/attestor/local_attestation_test.go b/link/internal/service/attestor/local_attestation_test.go new file mode 100644 index 000000000..c8e1f9ecc --- /dev/null +++ b/link/internal/service/attestor/local_attestation_test.go @@ -0,0 +1,329 @@ +package attestor + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/attestation" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +func testPacket(seq uint64) chains.Packet { + return chains.Packet{ + Sequence: seq, + SourceClient: "client-src", + DestClient: "client-dst", + TimeoutTimestamp: 1_700_000_000, + Payloads: []chains.Payload{{ + SourcePort: "transfer", + DestPort: "transfer", + Version: "ics20-1", + Encoding: "application/json", + Value: []byte("payload"), + }}, + } +} + +func encodePacket(t *testing.T, p chains.Packet) []byte { + t.Helper() + raw, err := attestation.EncodePacket(p) + require.NoError(t, err) + return raw +} + +// signerAddress derives the eth address of a local secp256k1 signer. +func signerAddress(t *testing.T, s *signer.LocalSecp256k1Signer) common.Address { + t.Helper() + pub, err := crypto.DecompressPubkey(s.PublicKey()) + require.NoError(t, err) + return crypto.PubkeyToAddress(*pub) +} + +func TestLocalStateAttestation(t *testing.T) { + ctx := context.Background() + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + addr := signerAddress(t, sgn) + + t.Run("happyPath", func(t *testing.T) { + // ARRANGE + ts := time.Unix(1_700_000_500, 0) + adapter := &fakeAdapter{finalizedHeight: 100, timestamp: ts} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + result, err := att.StateAttestation(ctx, 50) + + // ASSERT + require.NoError(t, err) + assert.Equal(t, uint64(50), result.Height) + require.NotNil(t, result.Timestamp) + assert.Equal(t, uint64(ts.Unix()), *result.Timestamp) + assert.Equal(t, attestation.EncodeStateAttestation(50, uint64(ts.Unix())), result.AttestedData) + + // signature recovers to the signer's address + digest := attestation.Digest(attestation.TypeState, result.AttestedData) + recovered, err := attestation.RecoverSigner(digest, result.Signature) + require.NoError(t, err) + assert.Equal(t, addr, recovered) + }) + + t.Run("deterministic", func(t *testing.T) { + // ARRANGE + adapter := &fakeAdapter{finalizedHeight: 100, timestamp: time.Unix(1_700_000_500, 0)} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + a, err := att.StateAttestation(ctx, 50) + require.NoError(t, err) + b, err := att.StateAttestation(ctx, 50) + require.NoError(t, err) + + // ASSERT: RFC6979 deterministic signatures + assert.Equal(t, a.Signature, b.Signature) + }) + + t.Run("notFinalized", func(t *testing.T) { + // ARRANGE + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.StateAttestation(ctx, 101) + + // ASSERT + require.ErrorIs(t, err, ErrNotFinalized) + }) +} + +func TestLocalPacketAttestation(t *testing.T) { + ctx := context.Background() + sgn, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + addr := signerAddress(t, sgn) + + const height = 50 + + t.Run("commitment", func(t *testing.T) { + // ARRANGE + pkt := testPacket(7) + path := attestation.PacketCommitmentPath(pkt.SourceClient, pkt.Sequence) + key := attestation.PathHash(path) + expected := attestation.PacketCommitment(pkt) + + adapter := &fakeAdapter{ + finalizedHeight: 100, + commitments: map[[32]byte][32]byte{key: expected}, + } + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + result, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketCommitment) + + // ASSERT + require.NoError(t, err) + assert.Nil(t, result.Timestamp) // absent for packet attestations + + gotHeight, compacts, err := attestation.DecodePacketAttestation(result.AttestedData) + require.NoError(t, err) + assert.Equal(t, uint64(height), gotHeight) + require.Len(t, compacts, 1) + assert.Equal(t, key, compacts[0].Path) + assert.Equal(t, expected, compacts[0].Commitment) + + digest := attestation.Digest(attestation.TypePacket, result.AttestedData) + recovered, err := attestation.RecoverSigner(digest, result.Signature) + require.NoError(t, err) + assert.Equal(t, addr, recovered) + }) + + t.Run("acknowledgement", func(t *testing.T) { + // ARRANGE + pkt := testPacket(7) + path := attestation.PacketAckCommitmentPath(pkt.DestClient, pkt.Sequence) + key := attestation.PathHash(path) + chainVal := [32]byte{0xAA, 0xBB} // used verbatim + + adapter := &fakeAdapter{ + finalizedHeight: 100, + commitments: map[[32]byte][32]byte{key: chainVal}, + } + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + result, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketAcknowledgement) + + // ASSERT + require.NoError(t, err) + _, compacts, err := attestation.DecodePacketAttestation(result.AttestedData) + require.NoError(t, err) + require.Len(t, compacts, 1) + assert.Equal(t, key, compacts[0].Path) + assert.Equal(t, chainVal, compacts[0].Commitment) + }) + + t.Run("receiptAbsence", func(t *testing.T) { + // ARRANGE: absent receipt (adapter returns zero) + pkt := testPacket(7) + path := attestation.PacketReceiptPath(pkt.DestClient, pkt.Sequence) + key := attestation.PathHash(path) + + adapter := &fakeAdapter{finalizedHeight: 100, commitments: map[[32]byte][32]byte{}} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + result, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketReceiptAbsence) + + // ASSERT + require.NoError(t, err) + _, compacts, err := attestation.DecodePacketAttestation(result.AttestedData) + require.NoError(t, err) + require.Len(t, compacts, 1) + assert.Equal(t, key, compacts[0].Path) + assert.Equal(t, zero32, compacts[0].Commitment) + }) + + t.Run("receiptPresentFails", func(t *testing.T) { + // ARRANGE: receipt present (non-zero) must be rejected + pkt := testPacket(7) + path := attestation.PacketReceiptPath(pkt.DestClient, pkt.Sequence) + key := attestation.PathHash(path) + + adapter := &fakeAdapter{ + finalizedHeight: 100, + commitments: map[[32]byte][32]byte{key: {0x01}}, + } + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketReceiptAbsence) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "receipt must be absent") + }) + + t.Run("commitmentNotFound", func(t *testing.T) { + // ARRANGE: adapter returns zero for the commitment + pkt := testPacket(7) + adapter := &fakeAdapter{finalizedHeight: 100, commitments: map[[32]byte][32]byte{}} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketCommitment) + + // ASSERT + require.ErrorIs(t, err, ErrCommitmentNotFound) + require.ErrorContains(t, err, "sequence=7") + }) + + t.Run("commitmentMismatch", func(t *testing.T) { + // ARRANGE: on-chain commitment != recomputed + pkt := testPacket(7) + path := attestation.PacketCommitmentPath(pkt.SourceClient, pkt.Sequence) + key := attestation.PathHash(path) + + adapter := &fakeAdapter{ + finalizedHeight: 100, + commitments: map[[32]byte][32]byte{key: {0xDE, 0xAD}}, + } + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, pkt)}, height, ProofTypePacketCommitment) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "packet commitment found but invalid") + }) + + t.Run("allOrNothing", func(t *testing.T) { + // ARRANGE: first packet valid, second missing → whole request fails + good := testPacket(1) + bad := testPacket(2) + goodKey := attestation.PathHash(attestation.PacketCommitmentPath(good.SourceClient, good.Sequence)) + + adapter := &fakeAdapter{ + finalizedHeight: 100, + commitments: map[[32]byte][32]byte{goodKey: attestation.PacketCommitment(good)}, + } + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.PacketAttestation( + ctx, + [][]byte{encodePacket(t, good), encodePacket(t, bad)}, + height, + ProofTypePacketCommitment, + ) + + // ASSERT + require.ErrorIs(t, err, ErrCommitmentNotFound) + }) + + t.Run("notFinalized", func(t *testing.T) { + // ARRANGE + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT + _, err := att.PacketAttestation(ctx, [][]byte{encodePacket(t, testPacket(1))}, 101, ProofTypePacketCommitment) + + // ASSERT + require.ErrorIs(t, err, ErrNotFinalized) + }) + + t.Run("emptyList", func(t *testing.T) { + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + _, err := att.PacketAttestation(ctx, nil, height, ProofTypePacketCommitment) + require.ErrorIs(t, err, ErrInvalidInput) + }) + + t.Run("emptyPacketBytes", func(t *testing.T) { + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + _, err := att.PacketAttestation(ctx, [][]byte{{}}, height, ProofTypePacketCommitment) + require.ErrorIs(t, err, ErrInvalidInput) + }) + + t.Run("tooManyPackets", func(t *testing.T) { + // ARRANGE: exceed the bound; checked before decoding, so junk bytes are fine + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + packets := make([][]byte, MaxPacketsPerAttestation+1) + for i := range packets { + packets[i] = []byte{0x01} + } + + // ACT + _, err := att.PacketAttestation(ctx, packets, height, ProofTypePacketCommitment) + + // ASSERT: no chain calls happened (bound checked first) + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "exceeding maximum") + assert.Zero(t, adapter.getCommitmentCalls.Load()) + }) + + t.Run("decodeFailure", func(t *testing.T) { + // ARRANGE + adapter := &fakeAdapter{finalizedHeight: 100} + att := must(NewLocal("chain-1", "alice", sgn, adapter)) + + // ACT: non-empty but undecodable bytes + _, err := att.PacketAttestation(ctx, [][]byte{{0x01, 0x02, 0x03}}, height, ProofTypePacketCommitment) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + }) +} diff --git a/link/internal/service/attestor/local_test.go b/link/internal/service/attestor/local_test.go index ebb1a64d8..4ac9b09d2 100644 --- a/link/internal/service/attestor/local_test.go +++ b/link/internal/service/attestor/local_test.go @@ -15,6 +15,8 @@ func TestLocal(t *testing.T) { eddsaSigner, err := signer.GenerateLocalEd25519Signer() require.NoError(t, err) + adapter := &fakeAdapter{} + t.Run("NewLocal", func(t *testing.T) { for _, tt := range []struct { name string @@ -22,6 +24,7 @@ func TestLocal(t *testing.T) { attestorName string chainID string signer signer.Signer + adapter ChainAdapter errContains string }{ @@ -30,12 +33,14 @@ func TestLocal(t *testing.T) { attestorName: "alice", chainID: "chain-1", signer: ecdsaSigner, + adapter: adapter, }, { name: "eddsaSigner", attestorName: "alice", chainID: "chain-1", signer: eddsaSigner, + adapter: adapter, errContains: "ECDSA signer required, got eddsa", }, { @@ -43,6 +48,7 @@ func TestLocal(t *testing.T) { attestorName: "alice", chainID: "", signer: ecdsaSigner, + adapter: adapter, errContains: "chainID required", }, { @@ -50,6 +56,7 @@ func TestLocal(t *testing.T) { attestorName: "", chainID: "chain-1", signer: ecdsaSigner, + adapter: adapter, errContains: "name required", }, { @@ -57,12 +64,21 @@ func TestLocal(t *testing.T) { signer: nil, attestorName: "alice", chainID: "chain-1", + adapter: adapter, errContains: "signer required", }, + { + name: "nilAdapter", + attestorName: "alice", + chainID: "chain-1", + signer: ecdsaSigner, + adapter: nil, + errContains: "chain adapter required", + }, } { t.Run(tt.name, func(t *testing.T) { // ACT - attestor, err := NewLocal(tt.chainID, tt.attestorName, tt.signer) + attestor, err := NewLocal(tt.chainID, tt.attestorName, tt.signer, tt.adapter) // ASSERT if tt.errContains != "" { @@ -72,10 +88,7 @@ func TestLocal(t *testing.T) { require.NoError(t, err) require.NotNil(t, attestor) - assert.Equal(t, tt.attestorName, attestor.Name()) assert.Equal(t, tt.attestorName, attestor.Alias()) - assert.Equal(t, tt.chainID, attestor.ChainID()) - assert.True(t, attestor.IsLocal()) }) } }) diff --git a/link/internal/service/attestor/remote.go b/link/internal/service/attestor/remote.go index 4952ba7c3..9c1ca6c13 100644 --- a/link/internal/service/attestor/remote.go +++ b/link/internal/service/attestor/remote.go @@ -6,8 +6,9 @@ import ( "net/http" "connectrpc.com/connect" + "github.com/pkg/errors" - proto "github.com/cosmos/ibc/link/internal/types/v2/attestor" + proto "github.com/cosmos/ibc/link/api/v2/attestor" ) // RemoteAttestor provides attestation data from a remote gRPC service. @@ -22,13 +23,15 @@ type RemoteAttestor struct { var _ Attestor = &RemoteAttestor{} -func NewRemoteFromURL(chainID, name, alias, grpcURL string) *RemoteAttestor { +// NewRemoteFromURL dials the attestor's gRPC endpoint. The request-side attestor +// name equals the local alias and the chain id is not known at this level. +func NewRemoteFromURL(alias, grpcURL string) *RemoteAttestor { var ( httpClient = newConnectHTTPClient() protoClient = proto.NewAttestationServiceClient(httpClient, grpcURL, connect.WithGRPC()) ) - return NewRemote(chainID, name, alias, protoClient) + return NewRemote("", alias, alias, protoClient) } func NewRemote(chainID, name, alias string, client proto.AttestationServiceClient) *RemoteAttestor { @@ -54,10 +57,73 @@ func (a *RemoteAttestor) LatestAttestableHeight(ctx context.Context) (uint64, er return res.Msg.Height, nil } -func (a *RemoteAttestor) Name() string { return a.name } -func (a *RemoteAttestor) Alias() string { return a.alias } -func (a *RemoteAttestor) ChainID() string { return a.chainID } -func (a *RemoteAttestor) IsLocal() bool { return false } +func (a *RemoteAttestor) StateAttestation(ctx context.Context, height uint64) (*Attestation, error) { + req := &proto.StateAttestationRequest{ + Attestor: a.name, + Height: height, + } + + res, err := a.client.StateAttestation(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + + return attestationFromProto(res.Msg.Attestation), nil +} + +func (a *RemoteAttestor) PacketAttestation( + ctx context.Context, + packets [][]byte, + height uint64, + proofType ProofType, +) (*Attestation, error) { + protoType, err := proofTypeToProto(proofType) + if err != nil { + return nil, err + } + + req := &proto.PacketAttestationRequest{ + Attestor: a.name, + Packets: packets, + Height: height, + ProofType: protoType, + } + + res, err := a.client.PacketAttestation(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + + return attestationFromProto(res.Msg.Attestation), nil +} + +func attestationFromProto(a *proto.Attestation) *Attestation { + if a == nil { + return nil + } + + return &Attestation{ + Height: a.Height, + Timestamp: a.Timestamp, + AttestedData: a.AttestedData, + Signature: a.Signature, + } +} + +func proofTypeToProto(proofType ProofType) (proto.ProofType, error) { + switch proofType { + case ProofTypePacketCommitment: + return proto.ProofType_PROOF_TYPE_PACKET_COMMITMENT, nil + case ProofTypePacketAcknowledgement: + return proto.ProofType_PROOF_TYPE_PACKET_ACKNOWLEDGEMENT, nil + case ProofTypePacketReceiptAbsence: + return proto.ProofType_PROOF_TYPE_PACKET_RECEIPT_ABSENCE, nil + default: + return 0, errors.Wrapf(ErrInvalidInput, "unknown proof type %d", proofType) + } +} + +func (a *RemoteAttestor) Alias() string { return a.alias } // https://connectrpc.com/docs/go/getting-started/#make-requests // todo: revisit these params diff --git a/link/internal/service/attestor/service.go b/link/internal/service/attestor/service.go index aee420996..60e0263bd 100644 --- a/link/internal/service/attestor/service.go +++ b/link/internal/service/attestor/service.go @@ -5,50 +5,69 @@ import ( "errors" "fmt" "log/slog" + "time" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/metrics" "github.com/cosmos/ibc/link/internal/service/signer" ) // Service manages configured attestors. type Service struct { attestors map[string]Attestor - logger *slog.Logger + + // adapters are the chain adapters owning dialed RPC connections, retained so + // Close can release them. It is nil for services built via New (e.g. tests) + // where the caller owns adapter lifetimes. + adapters []ChainAdapter + + metrics *metrics.Metrics } // Attestor reports attestation state. type Attestor interface { - // ChainID returns the chain ID. - ChainID() string - - // Name is the name of the attestor. It is NOT unique across the service - // Imagine there are 5 remote attestors and inside each they announce same `eth-1-attestor` name - Name() string - - // Alias is the internal unique name of the attestors within THIS process. + // Alias is the internal unique name of the attestor within THIS process. // Should be unique. Alias() string - // IsLocal returns true if the attestor is local. - IsLocal() bool - LatestAttestableHeight(ctx context.Context) (uint64, error) + + // StateAttestation produces a signed attestation for the state at height. + StateAttestation(ctx context.Context, height uint64) (*Attestation, error) + + // PacketAttestation produces a signed attestation for the given ABI-encoded + // packets at height under proofType. All packets must succeed or it fails. + PacketAttestation(ctx context.Context, packets [][]byte, height uint64, proofType ProofType) (*Attestation, error) } // Attestor errors var ( - ErrNotFound = errors.New("attestor not found") - ErrNoAttestations = errors.New("no attestations provided") + ErrNotFound = errors.New("attestor not found") + ErrNoAttestations = errors.New("no attestations provided") + ErrNotFinalized = errors.New("block is not finalized") + ErrInvalidInput = errors.New("invalid attestation input") + ErrCommitmentNotFound = errors.New("commitment not found") ) // NewFromConfig creates a new attestor service from the configuration. // Because config represents our local binary, ALL attestors are local. -func NewFromConfig(cfg config.Config, signers *signer.Set) (*Service, error) { +func NewFromConfig(cfg config.Config, signers *signer.Set, m *metrics.Metrics) (*Service, error) { if len(cfg.Attestor.Attestations) == 0 { return nil, ErrNoAttestations } attestorsSpecs := make([]Attestor, 0, len(cfg.Attestor.Attestations)) + adapters := make([]ChainAdapter, 0, len(cfg.Attestor.Attestations)) + + // unwind closes every adapter dialed so far, so a construction failure leaks + // no open RPC connections. + unwind := func() { + for _, adapter := range adapters { + if err := adapter.Close(); err != nil { + slog.Warn("closing attestor adapter during unwind", "err", err) + } + } + } add := func(spec config.AttestationConfig) error { s, ok := signers.Get(spec.Signer) @@ -56,7 +75,18 @@ func NewFromConfig(cfg config.Config, signers *signer.Set) (*Service, error) { return fmt.Errorf("unknown signer %s", spec.Signer) } - a, err := NewLocal(spec.ChainID, spec.Name, s) + chain, ok := cfg.Chain(spec.ChainID) + if !ok { + return fmt.Errorf("chainId %q not declared in top-level chains", spec.ChainID) + } + + adapter, err := NewChainAdapter(chain, spec) + if err != nil { + return err + } + adapters = append(adapters, adapter) + + a, err := NewLocal(spec.ChainID, spec.Name, s, adapter) if err != nil { return err } @@ -68,15 +98,39 @@ func NewFromConfig(cfg config.Config, signers *signer.Set) (*Service, error) { for _, spec := range cfg.Attestor.Attestations { if err := add(spec); err != nil { + unwind() return nil, fmt.Errorf("attestor %s: %w", spec.Name, err) } } - return New(attestorsSpecs) + svc, err := New(attestorsSpecs, m) + if err != nil { + unwind() + return nil, err + } + svc.adapters = adapters + + return svc, nil } -// New Service constructor. Attestors should have unique aliases -func New(attestors []Attestor) (*Service, error) { +// Close releases the dialed chain connections owned by the service's adapters. It +// is safe to call on a service built via New (no owned adapters) and joins any +// per-adapter close errors via errors.Join, so errors.Is still finds a +// sentinel/underlying per-adapter error through the returned error. +func (s *Service) Close() error { + var errs []error + for i, adapter := range s.adapters { + if err := adapter.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing attestor adapter %d: %w", i, err)) + } + } + + return errors.Join(errs...) +} + +// New Service constructor. Attestors should have unique aliases. A nil *Metrics +// disables request metrics (the recorder is a no-op). +func New(attestors []Attestor, m *metrics.Metrics) (*Service, error) { set := make(map[string]Attestor) for _, attestor := range attestors { alias := attestor.Alias() @@ -89,21 +143,63 @@ func New(attestors []Attestor) (*Service, error) { } return &Service{ - logger: slog.With("service", "attestors"), attestors: set, + metrics: m, }, nil } -// Add adds an attestor to the service. Not thread-safe. -func (s *Service) Add(id string, attestor Attestor) { - s.attestors[id] = attestor -} +// attestorUnknown is the fixed metrics label used for requests whose alias does +// not match a configured attestor. Recording the raw caller-supplied alias would +// let unauthenticated callers mint unbounded prometheus label series (a +// cardinality DoS); the fixed value keeps error visibility without that risk. +const attestorUnknown = "unknown" + +func (s *Service) LatestAttestableHeight(ctx context.Context, attestorAlias string) (height uint64, err error) { + label := attestorUnknown + defer func(start time.Time) { s.observe(label, metrics.MethodHeight, start, &err) }(time.Now()) -func (s *Service) LatestAttestableHeight(ctx context.Context, attestorAlias string) (uint64, error) { attestor, ok := s.attestors[attestorAlias] if !ok { return 0, ErrNotFound } + label = attestorAlias return attestor.LatestAttestableHeight(ctx) } + +// StateAttestation routes a state attestation request to the attestor identified +// by alias. An unknown alias returns ErrNotFound. +func (s *Service) StateAttestation(ctx context.Context, alias string, height uint64) (att *Attestation, err error) { + label := attestorUnknown + defer func(start time.Time) { s.observe(label, metrics.MethodState, start, &err) }(time.Now()) + + attestor, ok := s.attestors[alias] + if !ok { + return nil, ErrNotFound + } + label = alias + + return attestor.StateAttestation(ctx, height) +} + +// PacketAttestation routes a packet attestation request to the attestor identified +// by req.Attestor. An unknown alias returns ErrNotFound. +func (s *Service) PacketAttestation(ctx context.Context, req PacketAttestationRequest) (att *Attestation, err error) { + label := attestorUnknown + defer func(start time.Time) { s.observe(label, metrics.MethodPacket, start, &err) }(time.Now()) + + attestor, ok := s.attestors[req.Attestor] + if !ok { + return nil, ErrNotFound + } + label = req.Attestor + + return attestor.PacketAttestation(ctx, req.Packets, req.Height, req.ProofType) +} + +// observe records the request's duration and outcome under the attestor's alias +// (or attestorUnknown for unmatched requests). It is a no-op when metrics are not +// wired. +func (s *Service) observe(attestor, method string, start time.Time, err *error) { + s.metrics.ObserveAttestation(attestor, method, time.Since(start), *err) +} diff --git a/link/internal/service/attestor/service_close_test.go b/link/internal/service/attestor/service_close_test.go new file mode 100644 index 000000000..e6ef4bcb8 --- /dev/null +++ b/link/internal/service/attestor/service_close_test.go @@ -0,0 +1,44 @@ +package attestor + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestServiceCloseClosesAdapters verifies Service.Close releases every owned +// chain adapter (each of which holds a dialed RPC connection in production). +func TestServiceCloseClosesAdapters(t *testing.T) { + a1, a2 := &fakeAdapter{}, &fakeAdapter{} + svc := &Service{adapters: []ChainAdapter{a1, a2}} + + require.NoError(t, svc.Close()) + assert.True(t, a1.closed.Load(), "adapter 1 not closed") + assert.True(t, a2.closed.Load(), "adapter 2 not closed") +} + +// TestServiceCloseJoinsErrors verifies a failing adapter close still attempts the +// rest, surfaces the error, and preserves errors.Is on the underlying per-adapter +// error through the joined result. +func TestServiceCloseJoinsErrors(t *testing.T) { + errBoom := errors.New("boom") + bad := &fakeAdapter{closeErr: errBoom} + good := &fakeAdapter{} + svc := &Service{adapters: []ChainAdapter{bad, good}} + + err := svc.Close() + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.ErrorIs(t, err, errBoom, "joined error must still satisfy errors.Is for the underlying per-adapter error") + assert.True(t, good.closed.Load(), "sibling adapter not closed despite error") +} + +// TestServiceCloseNoAdapters verifies Close is a no-op for a service built via +// New (no owned adapters, e.g. tests). +func TestServiceCloseNoAdapters(t *testing.T) { + svc, err := New(nil, nil) + require.NoError(t, err) + require.NoError(t, svc.Close()) +} diff --git a/link/internal/service/attestor/service_metrics_test.go b/link/internal/service/attestor/service_metrics_test.go new file mode 100644 index 000000000..23413f6e7 --- /dev/null +++ b/link/internal/service/attestor/service_metrics_test.go @@ -0,0 +1,56 @@ +package attestor + +import ( + "context" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/metrics" + "github.com/cosmos/ibc/link/internal/service/signer" +) + +// TestServiceMetrics drives the service path and asserts the per-attestor +// request counter increments with the right {attestor, method, outcome} labels. +func TestServiceMetrics(t *testing.T) { + sampleSigner, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + + adapter := &fakeAdapter{finalizedHeight: 100} + + m := metrics.New() + service, err := New([]Attestor{ + must(NewLocal("1", "alice", sampleSigner, adapter)), + }, m) + require.NoError(t, err) + + ctx := context.Background() + + // One successful request against the known attestor. + _, err = service.LatestAttestableHeight(ctx, "alice") + require.NoError(t, err) + + // Two failing requests against DIFFERENT unknown attestors; both collapse to the + // fixed attestor="unknown" label so unauthenticated callers cannot mint unbounded + // label series (cardinality DoS). + _, err = service.LatestAttestableHeight(ctx, "zoe") + require.ErrorIs(t, err, ErrNotFound) + _, err = service.LatestAttestableHeight(ctx, "mallory") + require.ErrorIs(t, err, ErrNotFound) + + const metricName = "ibclink_attestor_requests_total" + + count, err := testutil.GatherAndCount(m.Registry(), metricName) + require.NoError(t, err) + require.Equal(t, 2, count, "expected one ok (alice) and one collapsed unknown label set") + + expected := ` +# HELP ibclink_attestor_requests_total Attestation requests handled, by attestor, method, and outcome. +# TYPE ibclink_attestor_requests_total counter +ibclink_attestor_requests_total{attestor="alice",method="height",outcome="ok"} 1 +ibclink_attestor_requests_total{attestor="unknown",method="height",outcome="error"} 2 +` + require.NoError(t, testutil.GatherAndCompare(m.Registry(), strings.NewReader(expected), metricName)) +} diff --git a/link/internal/service/attestor/service_test.go b/link/internal/service/attestor/service_test.go index 473c0b315..f85745a0e 100644 --- a/link/internal/service/attestor/service_test.go +++ b/link/internal/service/attestor/service_test.go @@ -3,7 +3,6 @@ package attestor import ( "context" "testing" - "time" "connectrpc.com/connect" "github.com/cosmos/ibc/link/internal/service/signer" @@ -17,15 +16,17 @@ func TestService(t *testing.T) { sampleSigner, err := signer.GenerateLocalSecp256k1Signer() require.NoError(t, err) + adapter := &fakeAdapter{finalizedHeight: 100} + t.Run("duplicateAliases", func(t *testing.T) { // ARRANGE attestors := []Attestor{ - must(NewLocal("1", "alice", sampleSigner)), - must(NewLocal("2", "alice", sampleSigner)), + must(NewLocal("1", "alice", sampleSigner, adapter)), + must(NewLocal("2", "alice", sampleSigner, adapter)), } // ACT - service, err := New(attestors) + service, err := New(attestors, nil) // ASSERT require.ErrorContains(t, err, "attestor with alias alice already exists") @@ -36,14 +37,12 @@ func TestService(t *testing.T) { // ARRANGE ctx := context.Background() service, err := New([]Attestor{ - must(NewLocal("1", "alice", sampleSigner)), - must(NewLocal("2", "bob", sampleSigner)), - must(NewLocal("3", "carol", sampleSigner)), - }) + must(NewLocal("1", "alice", sampleSigner, adapter)), + must(NewLocal("2", "bob", sampleSigner, adapter)), + must(NewLocal("3", "carol", sampleSigner, adapter)), + }, nil) require.NoError(t, err) - start := uint64(time.Now().Unix()) - for _, alias := range []string{"alice", "bob", "carol"} { t.Run(alias, func(t *testing.T) { // ACT @@ -51,8 +50,7 @@ func TestService(t *testing.T) { // ASSERT require.NoError(t, err) - assert.GreaterOrEqual(t, height, start) - assert.LessOrEqual(t, height, uint64(time.Now().Unix())) + assert.Equal(t, uint64(100), height) }) } @@ -74,7 +72,7 @@ func TestService(t *testing.T) { NewRemote("ethereum", "alice", "eth-alice", client), NewRemote("cosmos", "bob", "cosmos-bob", client), NewRemote("solana", "carol", "solana-carol", client), - }) + }, nil) require.NoError(t, err) for _, tt := range []struct { @@ -134,10 +132,9 @@ func TestService(t *testing.T) { NewRemote("ethereum", "alice", "eth-alice", client), NewRemote("cosmos", "bob", "cosmos-bob", client), NewRemote("solana", "carol", "solana-carol", client), - must(NewLocal("ethereum", "dave", sampleSigner)), - }) + must(NewLocal("ethereum", "dave", sampleSigner, adapter)), + }, nil) require.NoError(t, err) - start := uint64(time.Now().Unix()) client.EXPECT(). LatestAttestableHeight(mock.Anything, latestAttestableHeightRequest("bob")). Return(connect.NewResponse(&proto.LatestAttestableHeightResponse{ @@ -157,8 +154,7 @@ func TestService(t *testing.T) { // ASSERT require.NoError(t, err) - assert.GreaterOrEqual(t, localHeight, start) - assert.LessOrEqual(t, localHeight, uint64(time.Now().Unix())) + assert.Equal(t, uint64(100), localHeight) }) } diff --git a/link/internal/service/attestor/types.go b/link/internal/service/attestor/types.go new file mode 100644 index 000000000..d127519e9 --- /dev/null +++ b/link/internal/service/attestor/types.go @@ -0,0 +1,46 @@ +package attestor + +// MaxPacketsPerAttestation bounds the number of packets accepted in a single +// PacketAttestation request, matching the Rust attestor. +const MaxPacketsPerAttestation = 100 + +// ProofType identifies which packet proof an attestation is produced for. +type ProofType uint8 + +// Packet proof types. +const ( + ProofTypePacketCommitment ProofType = iota + ProofTypePacketAcknowledgement + ProofTypePacketReceiptAbsence +) + +// Attestation is a signed attestation over ABI-encoded attested data. +type Attestation struct { + // Height the attestation is pinned at. + Height uint64 + + // Timestamp of the block. Present for state attestations, absent (nil) for + // packet attestations. + Timestamp *uint64 + + // AttestedData is the raw (untagged) ABI-encoded struct that was signed. + AttestedData []byte + + // Signature is the 65-byte recoverable secp256k1 signature (r||s||v, v∈{27,28}). + Signature []byte +} + +// PacketAttestationRequest is the routed input to Service.PacketAttestation. +type PacketAttestationRequest struct { + // Attestor is the alias of the attestor to route the request to. + Attestor string + + // Packets are independently ABI-encoded packets to attest to. + Packets [][]byte + + // Height to attest the packets at. + Height uint64 + + // ProofType to attest. + ProofType ProofType +} diff --git a/link/internal/service/relayer/service.go b/link/internal/service/relayer/service.go index 44a9d3be0..c348329fb 100644 --- a/link/internal/service/relayer/service.go +++ b/link/internal/service/relayer/service.go @@ -1,40 +1,463 @@ +// Package relayer implements the relayer business logic. package relayer import ( "context" + "fmt" "log/slog" + "time" + "github.com/ethereum/go-ethereum/common" "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/store" + + ethereum "github.com/ethereum/go-ethereum" ) // Service represents relayer business logic. type Service struct { logger *slog.Logger + cfg config.Config + store Store + chains ChainClientManager } -// New Service constructor. -func New() *Service { - return &Service{ - logger: slog.With("service", "relayer"), - } +// ChainClientManager resolves chain clients by chain id. +type ChainClientManager interface { + GetClient(chainID string) (chains.Client, error) +} + +// Store queries used by the relayer service. +type Store interface { + GetRelayRequest(ctx context.Context, chainID string, txHash string) (*store.RelayRequest, error) + ListPacketsBySourceTx(ctx context.Context, chainID string, txHash string) ([]store.Packet, error) + ListPackets(ctx context.Context, filter store.ListPacketsFilter) ([]store.Packet, error) + GetPacket(ctx context.Context, key store.PacketKey) (*store.Packet, error) + ExecTx(ctx context.Context, fn func(store.Repository) error) error } // Relay errors var ( ErrInvalidInput = errors.New("invalid input") + ErrNotFound = errors.New("not found") + // ErrUpstream indicates a failure talking to an upstream chain RPC while + // resolving a relay request (as opposed to bad input or a missing packet). + ErrUpstream = errors.New("upstream chain error") ) -func (s *Service) Relay(_ context.Context, chainID, txHash string) error { +// PacketState is the derived lifecycle state of a packet. It is the single +// vocabulary both the HTTP and Connect adapters translate from; the HTTP wire +// surface distinguishes all five states, while the coarser Connect proto maps +// the terminal states down (timed_out and error_ack both read as complete). +type PacketState int + +// Packet states +const ( + StatePending PacketState = iota + StateComplete + StateTimedOut + StateErrorAck + StateFailed +) + +// String returns the coarse wire vocabulary for the state (the same names the +// HTTP /status surface and the `ibc status` CLI report). An unrecognized value +// reports "unknown". +func (s PacketState) String() string { + switch s { + case StatePending: + return "pending" + case StateComplete: + return "complete" + case StateTimedOut: + return "timed_out" + case StateErrorAck: + return "error_ack" + case StateFailed: + return "failed" + default: + return "unknown" + } +} + +// Terminal reports whether the state is a resolved (non-pending) outcome. The +// `ibc status` surface treats every non-terminal packet as a candidate for its +// stuck list. +func (s PacketState) Terminal() bool { + return s != StatePending +} + +// DeriveStatus derives the unified PacketStatus DTO from a stored packet: its +// identity, lifecycle state, terminal reason/effect, and per-step tx info. It is +// a pure function (no chain or store access), exposing the single lifecycle +// derivation used by the HTTP and Connect adapters to out-of-band consumers such +// as the `ibc status` operator command, so its state/reason classification never +// drifts from the running daemon's. +func DeriveStatus(p store.Packet) PacketStatus { + state, effectTxHash, reason := deriveState(p) + + return PacketStatus{ + State: state, + Reason: reason, + EffectTxHash: effectTxHash, + SequenceNumber: p.PacketSequenceNumber, + SourceChainID: p.SourceChainID, + SourceClientID: p.PacketSourceClientID, + SendTx: TxInfo{TxHash: p.SourceTxHash, ChainID: p.SourceChainID}, + RecvTx: toTxInfo(p.RecvTxHash, p.DestinationChainID), + AckTx: toTxInfo(p.AckTxHash, p.SourceChainID), + TimeoutTx: toTxInfo(p.TimeoutTxHash, p.SourceChainID), + } +} + +// Terminal-state reasons surfaced alongside a derived status. +const ( + reasonErrorAck = "destination application returned error acknowledgement" + reasonFailed = "relaying failed permanently" + // reasonInvariant is surfaced for a packet that reached a state violating a + // protocol invariant: the source commitment was cleared (implying an ack or + // timeout on chain) but no such event could be found. It is deliberately more + // specific than reasonFailed so operators can distinguish this from an ordinary + // permanent relay failure. + reasonInvariant = "source commitment cleared but no ack or timeout event found on chain" +) + +// TxInfo a transaction on a chain. +type TxInfo struct { + TxHash string + ChainID string +} + +// StatusFilter narrows a ListPacketStatuses query to a single source route. A +// zero value lists every tracked packet. +type StatusFilter struct { + SourceChainID string + SourceClientID string +} + +// PacketStatus is the unified relay-status DTO derived from a stored packet. It +// is the single source of lifecycle-state derivation: the HTTP adapter maps it +// to the /status wire shape (State, Reason, EffectTxHash) and the Connect +// adapter maps it to the proto response (per-step tx info + coarse state). +type PacketStatus struct { + State PacketState + Reason string + EffectTxHash string + SequenceNumber uint64 + SourceChainID string + SourceClientID string + SendTx TxInfo + RecvTx *TxInfo + AckTx *TxInfo + TimeoutTx *TxInfo +} + +// New Service constructor. +func New(cfg config.Config, st Store, clientManager ChainClientManager) *Service { + return &Service{ + logger: slog.With("service", "relayer"), + cfg: cfg, + store: st, + chains: clientManager, + } +} + +// Relay records a relay request for the source tx and returns the derived +// status (freshly pending) of each packet discovered in it. +func (s *Service) Relay(ctx context.Context, chainID, txHash string) ([]PacketStatus, error) { + txHash, err := s.validateRelayArgs(chainID, txHash) + if err != nil { + return nil, err + } + + client, err := s.chains.GetClient(chainID) + if err != nil { + return nil, errors.Wrapf(err, "getting chain client for %q", chainID) + } + + // txHash is already the canonical 0x-prefixed hash returned by validateRelayArgs. + events, err := client.TxPacketEvents(ctx, common.HexToHash(txHash).Bytes()) + switch { + case errors.Is(err, ethereum.NotFound): + // No receipt: the tx is unknown to the chain. Treat as "no packet found" + // so the wire surface returns a 404 rather than a 5xx. + return nil, errors.Wrapf(ErrNotFound, "no packet found for chainID %q txHash %q", chainID, txHash) + case errors.Is(err, chains.ErrTxReverted): + return nil, errors.Wrapf(ErrInvalidInput, "transaction %s reverted on chain %s", txHash, chainID) + case err != nil: + return nil, errors.Wrap(ErrUpstream, err.Error()) + } + + packets, err := s.packetsFromEvents(chainID, txHash, events) + if err != nil { + return nil, err + } + + if len(packets) == 0 { + return nil, errors.Wrapf(ErrNotFound, "no packet found for chainID %q txHash %q", chainID, txHash) + } + + err = s.store.ExecTx(ctx, func(repo store.Repository) error { + if errCreate := repo.CreateRelayRequest(ctx, chainID, txHash); errCreate != nil { + return errors.Wrap(errCreate, "creating relay request") + } + + for _, packet := range packets { + if errCreate := repo.CreatePacket(ctx, packet); errCreate != nil { + return errors.Wrapf(errCreate, "creating packet for packet %d", packet.PacketSequenceNumber) + } + } + + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "recording relay request") + } + + s.logger.Info("Recorded relay request", "chainID", chainID, "txHash", txHash, "packets", len(packets)) + + statuses := make([]PacketStatus, len(packets)) + for i, packet := range packets { + statuses[i] = PacketStatus{ + State: StatePending, + SequenceNumber: packet.PacketSequenceNumber, + SourceChainID: packet.SourceChainID, + SourceClientID: packet.PacketSourceClientID, + SendTx: TxInfo{TxHash: packet.SourceTxHash, ChainID: packet.SourceChainID}, + } + } + + return statuses, nil +} + +// packetsFromEvents converts send-packet events into store rows. A packet from +// a client this relayer is not configured for fails the whole request: silently +// accepting it would strand the packet with no operator signal. +func (s *Service) packetsFromEvents( + chainID, txHash string, + events []chains.PacketEvent, +) ([]store.CreatePacket, error) { + var packets []store.CreatePacket + + for _, event := range events { + if event.Kind != chains.KindSendPacket { + continue + } + + client, ok := s.cfg.Relayer.Client(chainID, event.Packet.SourceClient) + if !ok { + return nil, errors.Wrapf( + ErrInvalidInput, + "packet sequence %d references client %q on chain %q which this relayer is not configured for", + event.Packet.Sequence, event.Packet.SourceClient, chainID, + ) + } + + // Only clients with a configured relay route are accepted; the engine + // builds routes solely for routesToRelay sources, so a routeless client + // would persist packets the engine can never advance. + if !s.cfg.Relayer.HasRoute(client.Alias) { + return nil, errors.Wrapf( + ErrInvalidInput, + "packet sequence %d references client %q on chain %q which has no configured relay route", + event.Packet.Sequence, event.Packet.SourceClient, chainID, + ) + } + + packets = append(packets, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainID, + DestinationChainID: client.CounterpartyChainID, + SourceTxHash: txHash, + SourceTxTime: event.BlockTime, + PacketSequenceNumber: event.Packet.Sequence, + PacketSourceClientID: event.Packet.SourceClient, + PacketDestinationClientID: event.Packet.DestClient, + PacketTimeoutTimestamp: unixTime(event.Packet.TimeoutTimestamp), + }) + } + + return packets, nil +} + +func (s *Service) Status(ctx context.Context, chainID, txHash string) ([]PacketStatus, error) { + txHash, err := s.validateRelayArgs(chainID, txHash) + if err != nil { + return nil, err + } + + switch _, errGet := s.store.GetRelayRequest(ctx, chainID, txHash); { + case errors.Is(errGet, store.ErrNotFound): + return nil, errors.Wrap(ErrNotFound, "transaction not submitted to relayer") + case errGet != nil: + return nil, errors.Wrap(errGet, "getting relay request") + } + + packets, err := s.store.ListPacketsBySourceTx(ctx, chainID, txHash) + if err != nil { + return nil, errors.Wrap(err, "listing packets") + } + + statuses := make([]PacketStatus, len(packets)) + for i, packet := range packets { + statuses[i] = DeriveStatus(packet) + } + + return statuses, nil +} + +// ListPacketStatuses returns the derived status of every packet matching filter, +// pushing the (optional) source-route scope into the store's indexed lookup. +func (s *Service) ListPacketStatuses(ctx context.Context, filter StatusFilter) ([]PacketStatus, error) { + storeFilter := store.ListPacketsFilter{} + if filter.SourceChainID != "" { + storeFilter.SourceChainID = &filter.SourceChainID + } + if filter.SourceClientID != "" { + storeFilter.SourceClientID = &filter.SourceClientID + } + + packets, err := s.store.ListPackets(ctx, storeFilter) + if err != nil { + return nil, errors.Wrap(err, "listing packets") + } + + statuses := make([]PacketStatus, len(packets)) + for i, packet := range packets { + statuses[i] = DeriveStatus(packet) + } + + return statuses, nil +} + +// GetPacketStatus returns the derived status of a single packet identified by +// its source chain, source client, and sequence. ok is false when no such +// packet exists. +func (s *Service) GetPacketStatus( + ctx context.Context, + chainID, clientID string, + sequence uint64, +) (PacketStatus, bool, error) { + packet, err := s.store.GetPacket(ctx, store.PacketKey{ + SourceChainID: chainID, + PacketSequenceNumber: sequence, + PacketSourceClientID: clientID, + }) + if errors.Is(err, store.ErrNotFound) { + return PacketStatus{}, false, nil + } + if err != nil { + return PacketStatus{}, false, errors.Wrap(err, "getting packet") + } + + return DeriveStatus(*packet), true, nil +} + +// validateRelayArgs validates the tx hash for the chain's type and applies +// consistent casing so lookups are case-insensitive. +func (s *Service) validateRelayArgs(chainID, txHash string) (string, error) { switch { case chainID == "": - return errors.Wrap(ErrInvalidInput, "chainID is required") + return "", errors.Wrap(ErrInvalidInput, "chainID is required") case txHash == "": - return errors.Wrap(ErrInvalidInput, "txHash is required") + return "", errors.Wrap(ErrInvalidInput, "txHash is required") + } + + chain, ok := s.cfg.Chain(chainID) + if !ok { + return "", errors.Wrapf(ErrInvalidInput, "unsupported chain %q", chainID) + } + + switch chain.Type() { + case config.ChainTypeEVM: + var hash common.Hash + if err := hash.UnmarshalText([]byte(txHash)); err != nil { + return "", errors.Wrapf(ErrInvalidInput, "txHash %q is not a valid evm transaction hash", txHash) + } + + return hash.Hex(), nil + default: + return "", errors.Wrapf(ErrInvalidInput, "unsupported chain type for chain %q", chainID) + } +} + +// deriveState maps a stored packet to its lifecycle state, on-chain effect tx +// hash, and terminal reason: +// - COMPLETE_WITH_TIMEOUT -> timed_out (effect = timeout tx) +// - FAILED -> failed ("relaying failed permanently"; no fabricated effect hash) +// - FAILED_INVARIANT -> failed (invariant-violation reason; no fabricated effect hash) +// - terminal ack (COMPLETE_WITH_ACK / COMPLETE_WITH_WRITE_ACK_ERROR) with +// WriteAckStatus==ERROR -> error_ack, effect = confirmed source ack tx hash +// (falling back to the recv hash only if the ack hash is absent) +// - other terminal ack states -> complete (effect = recv tx) +// - everything else (including a not-yet-terminal errored write ack) -> pending +// +// error_ack is reported ONLY once the ack/refund is terminal on the source +// chain; until then the effect is unconfirmed and the state stays pending. +func deriveState(p store.Packet) (state PacketState, effectTxHash, reason string) { + switch p.Status { + case store.RelayStatusCompleteWithTimeout: + return StateTimedOut, derefString(p.TimeoutTxHash), timeoutReason(p) + + case store.RelayStatusFailed: + return StateFailed, "", reasonFailed + + case store.RelayStatusFailedInvariant: + return StateFailed, "", reasonInvariant + + case store.RelayStatusCompleteWithAck, + store.RelayStatusCompleteWithWriteAckError: + if p.WriteAckStatus != nil && *p.WriteAckStatus == store.WriteAckStatusError { + return StateErrorAck, ackEffectHash(p), reasonErrorAck + } + + return StateComplete, derefString(p.RecvTxHash), "" + + case store.RelayStatusCompleteWithWriteAckSuccess: + return StateComplete, derefString(p.RecvTxHash), "" + + default: + return StatePending, "", "" + } +} + +// ackEffectHash returns the confirmed source ack tx hash as the terminal effect +// for an errored ack, falling back to the recv hash only when the ack hash is +// not yet recorded. +func ackEffectHash(p store.Packet) string { + if p.AckTxHash != nil && *p.AckTxHash != "" { + return *p.AckTxHash + } + + return derefString(p.RecvTxHash) +} + +func timeoutReason(p store.Packet) string { + return fmt.Sprintf( + "packet timed out at %s; timeout submitted on source chain", + p.PacketTimeoutTimestamp.UTC().Format(time.RFC3339), + ) +} + +func derefString(s *string) string { + if s == nil { + return "" } - s.logger.Info("Relaying transaction", "chainID", chainID, "txHash", txHash) + return *s +} - // todo +func toTxInfo(txHash *string, chainID string) *TxInfo { + if txHash == nil { + return nil + } + + return &TxInfo{TxHash: *txHash, ChainID: chainID} +} - return nil +func unixTime(seconds uint64) time.Time { + return time.Unix(int64(seconds), 0).UTC() //nolint:gosec // timeout timestamps fit in int64 } diff --git a/link/internal/service/relayer/service.mock.go b/link/internal/service/relayer/service.mock.go new file mode 100644 index 000000000..a263ea61e --- /dev/null +++ b/link/internal/service/relayer/service.mock.go @@ -0,0 +1,469 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package relayer + +import ( + "context" + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/store" + mock "github.com/stretchr/testify/mock" +) + +// NewMockChainClientManager creates a new instance of MockChainClientManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockChainClientManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockChainClientManager { + mock := &MockChainClientManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockChainClientManager is an autogenerated mock type for the ChainClientManager type +type MockChainClientManager struct { + mock.Mock +} + +type MockChainClientManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockChainClientManager) EXPECT() *MockChainClientManager_Expecter { + return &MockChainClientManager_Expecter{mock: &_m.Mock} +} + +// GetClient provides a mock function for the type MockChainClientManager +func (_mock *MockChainClientManager) GetClient(chainID string) (chains.Client, error) { + ret := _mock.Called(chainID) + + if len(ret) == 0 { + panic("no return value specified for GetClient") + } + + var r0 chains.Client + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (chains.Client, error)); ok { + return returnFunc(chainID) + } + if returnFunc, ok := ret.Get(0).(func(string) chains.Client); ok { + r0 = returnFunc(chainID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chains.Client) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(chainID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockChainClientManager_GetClient_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetClient' +type MockChainClientManager_GetClient_Call struct { + *mock.Call +} + +// GetClient is a helper method to define mock.On call +// - chainID string +func (_e *MockChainClientManager_Expecter) GetClient(chainID any) *MockChainClientManager_GetClient_Call { + return &MockChainClientManager_GetClient_Call{Call: _e.mock.On("GetClient", chainID)} +} + +func (_c *MockChainClientManager_GetClient_Call) Run(run func(chainID string)) *MockChainClientManager_GetClient_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockChainClientManager_GetClient_Call) Return(client chains.Client, err error) *MockChainClientManager_GetClient_Call { + _c.Call.Return(client, err) + return _c +} + +func (_c *MockChainClientManager_GetClient_Call) RunAndReturn(run func(chainID string) (chains.Client, error)) *MockChainClientManager_GetClient_Call { + _c.Call.Return(run) + return _c +} + +// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockStore(t interface { + mock.TestingT + Cleanup(func()) +}) *MockStore { + mock := &MockStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockStore is an autogenerated mock type for the Store type +type MockStore struct { + mock.Mock +} + +type MockStore_Expecter struct { + mock *mock.Mock +} + +func (_m *MockStore) EXPECT() *MockStore_Expecter { + return &MockStore_Expecter{mock: &_m.Mock} +} + +// ExecTx provides a mock function for the type MockStore +func (_mock *MockStore) ExecTx(ctx context.Context, fn func(store.Repository) error) error { + ret := _mock.Called(ctx, fn) + + if len(ret) == 0 { + panic("no return value specified for ExecTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, func(store.Repository) error) error); ok { + r0 = returnFunc(ctx, fn) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_ExecTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecTx' +type MockStore_ExecTx_Call struct { + *mock.Call +} + +// ExecTx is a helper method to define mock.On call +// - ctx context.Context +// - fn func(store.Repository) error +func (_e *MockStore_Expecter) ExecTx(ctx any, fn any) *MockStore_ExecTx_Call { + return &MockStore_ExecTx_Call{Call: _e.mock.On("ExecTx", ctx, fn)} +} + +func (_c *MockStore_ExecTx_Call) Run(run func(ctx context.Context, fn func(store.Repository) error)) *MockStore_ExecTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 func(store.Repository) error + if args[1] != nil { + arg1 = args[1].(func(store.Repository) error) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_ExecTx_Call) Return(err error) *MockStore_ExecTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_ExecTx_Call) RunAndReturn(run func(ctx context.Context, fn func(store.Repository) error) error) *MockStore_ExecTx_Call { + _c.Call.Return(run) + return _c +} + +// GetPacket provides a mock function for the type MockStore +func (_mock *MockStore) GetPacket(ctx context.Context, key store.PacketKey) (*store.Packet, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for GetPacket") + } + + var r0 *store.Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, store.PacketKey) (*store.Packet, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, store.PacketKey) *store.Packet); ok { + r0 = returnFunc(ctx, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, store.PacketKey) error); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_GetPacket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPacket' +type MockStore_GetPacket_Call struct { + *mock.Call +} + +// GetPacket is a helper method to define mock.On call +// - ctx context.Context +// - key store.PacketKey +func (_e *MockStore_Expecter) GetPacket(ctx any, key any) *MockStore_GetPacket_Call { + return &MockStore_GetPacket_Call{Call: _e.mock.On("GetPacket", ctx, key)} +} + +func (_c *MockStore_GetPacket_Call) Run(run func(ctx context.Context, key store.PacketKey)) *MockStore_GetPacket_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 store.PacketKey + if args[1] != nil { + arg1 = args[1].(store.PacketKey) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_GetPacket_Call) Return(packet *store.Packet, err error) *MockStore_GetPacket_Call { + _c.Call.Return(packet, err) + return _c +} + +func (_c *MockStore_GetPacket_Call) RunAndReturn(run func(ctx context.Context, key store.PacketKey) (*store.Packet, error)) *MockStore_GetPacket_Call { + _c.Call.Return(run) + return _c +} + +// GetRelayRequest provides a mock function for the type MockStore +func (_mock *MockStore) GetRelayRequest(ctx context.Context, chainID string, txHash string) (*store.RelayRequest, error) { + ret := _mock.Called(ctx, chainID, txHash) + + if len(ret) == 0 { + panic("no return value specified for GetRelayRequest") + } + + var r0 *store.RelayRequest + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*store.RelayRequest, error)); ok { + return returnFunc(ctx, chainID, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *store.RelayRequest); ok { + r0 = returnFunc(ctx, chainID, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.RelayRequest) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, chainID, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_GetRelayRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayRequest' +type MockStore_GetRelayRequest_Call struct { + *mock.Call +} + +// GetRelayRequest is a helper method to define mock.On call +// - ctx context.Context +// - chainID string +// - txHash string +func (_e *MockStore_Expecter) GetRelayRequest(ctx any, chainID any, txHash any) *MockStore_GetRelayRequest_Call { + return &MockStore_GetRelayRequest_Call{Call: _e.mock.On("GetRelayRequest", ctx, chainID, txHash)} +} + +func (_c *MockStore_GetRelayRequest_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockStore_GetRelayRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockStore_GetRelayRequest_Call) Return(relayRequest *store.RelayRequest, err error) *MockStore_GetRelayRequest_Call { + _c.Call.Return(relayRequest, err) + return _c +} + +func (_c *MockStore_GetRelayRequest_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) (*store.RelayRequest, error)) *MockStore_GetRelayRequest_Call { + _c.Call.Return(run) + return _c +} + +// ListPackets provides a mock function for the type MockStore +func (_mock *MockStore) ListPackets(ctx context.Context, filter store.ListPacketsFilter) ([]store.Packet, error) { + ret := _mock.Called(ctx, filter) + + if len(ret) == 0 { + panic("no return value specified for ListPackets") + } + + var r0 []store.Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, store.ListPacketsFilter) ([]store.Packet, error)); ok { + return returnFunc(ctx, filter) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, store.ListPacketsFilter) []store.Packet); ok { + r0 = returnFunc(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, store.ListPacketsFilter) error); ok { + r1 = returnFunc(ctx, filter) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_ListPackets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPackets' +type MockStore_ListPackets_Call struct { + *mock.Call +} + +// ListPackets is a helper method to define mock.On call +// - ctx context.Context +// - filter store.ListPacketsFilter +func (_e *MockStore_Expecter) ListPackets(ctx any, filter any) *MockStore_ListPackets_Call { + return &MockStore_ListPackets_Call{Call: _e.mock.On("ListPackets", ctx, filter)} +} + +func (_c *MockStore_ListPackets_Call) Run(run func(ctx context.Context, filter store.ListPacketsFilter)) *MockStore_ListPackets_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 store.ListPacketsFilter + if args[1] != nil { + arg1 = args[1].(store.ListPacketsFilter) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_ListPackets_Call) Return(packets []store.Packet, err error) *MockStore_ListPackets_Call { + _c.Call.Return(packets, err) + return _c +} + +func (_c *MockStore_ListPackets_Call) RunAndReturn(run func(ctx context.Context, filter store.ListPacketsFilter) ([]store.Packet, error)) *MockStore_ListPackets_Call { + _c.Call.Return(run) + return _c +} + +// ListPacketsBySourceTx provides a mock function for the type MockStore +func (_mock *MockStore) ListPacketsBySourceTx(ctx context.Context, chainID string, txHash string) ([]store.Packet, error) { + ret := _mock.Called(ctx, chainID, txHash) + + if len(ret) == 0 { + panic("no return value specified for ListPacketsBySourceTx") + } + + var r0 []store.Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]store.Packet, error)); ok { + return returnFunc(ctx, chainID, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []store.Packet); ok { + r0 = returnFunc(ctx, chainID, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, chainID, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_ListPacketsBySourceTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPacketsBySourceTx' +type MockStore_ListPacketsBySourceTx_Call struct { + *mock.Call +} + +// ListPacketsBySourceTx is a helper method to define mock.On call +// - ctx context.Context +// - chainID string +// - txHash string +func (_e *MockStore_Expecter) ListPacketsBySourceTx(ctx any, chainID any, txHash any) *MockStore_ListPacketsBySourceTx_Call { + return &MockStore_ListPacketsBySourceTx_Call{Call: _e.mock.On("ListPacketsBySourceTx", ctx, chainID, txHash)} +} + +func (_c *MockStore_ListPacketsBySourceTx_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockStore_ListPacketsBySourceTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockStore_ListPacketsBySourceTx_Call) Return(packets []store.Packet, err error) *MockStore_ListPacketsBySourceTx_Call { + _c.Call.Return(packets, err) + return _c +} + +func (_c *MockStore_ListPacketsBySourceTx_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) ([]store.Packet, error)) *MockStore_ListPacketsBySourceTx_Call { + _c.Call.Return(run) + return _c +} diff --git a/link/internal/service/relayer/service_test.go b/link/internal/service/relayer/service_test.go new file mode 100644 index 000000000..58e7c07a5 --- /dev/null +++ b/link/internal/service/relayer/service_test.go @@ -0,0 +1,623 @@ +package relayer + +import ( + "context" + "encoding/hex" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/chains" + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/store" +) + +const ( + chainIDEth = "1" + chainIDBase = "8453" + txHashLower = "0x60016c34c02278856c81a41ce857ac4bb837a2f4a13c95207e08cbc9e8f2b706" + txHashUpper = "0x60016C34C02278856C81A41CE857AC4BB837A2F4A13C95207E08CBC9E8F2B706" +) + +func relayerConfig() config.Config { + return config.Config{ + Chains: []config.ChainConfig{ + { + ChainID: chainIDEth, + EVM: &config.EVMChainConfig{ + RPC: "https://ethereum-rpc.example.com", + ICS26Router: "0x0000000000000000000000000000000000000000", + }, + }, + }, + Relayer: config.RelayerConfig{ + Clients: []config.ClientConfig{ + { + Alias: "base-0", + ClientID: "base-0", + ChainID: chainIDEth, + CounterpartyChainID: chainIDBase, + Type: config.ClientTypeAttestation, + }, + }, + Routes: []config.RouteConfig{{SourceClient: "base-0"}}, + }, + } +} + +func txHashBytes(t *testing.T) []byte { + t.Helper() + + raw, err := hex.DecodeString(txHashLower[2:]) + require.NoError(t, err) + + return raw +} + +func TestRelay(t *testing.T) { + t.Run("tracksExtractedPacketsAtomically", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + st := NewMockStore(t) + repo := store.NewMockRepository(t) + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), st, clientManager) + + blockTime := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + events := []chains.PacketEvent{ + { + Height: 100, + BlockTime: blockTime, + Kind: chains.KindSendPacket, + Packet: chains.Packet{ + Sequence: 42, + SourceClient: "base-0", + DestClient: "ethereum-0", + TimeoutTimestamp: 1780000000, + }, + }, + } + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(events, nil).Once() + + // request and packets land in one transaction; hash normalized to lowercase + st.EXPECT(). + ExecTx(ctx, mock.AnythingOfType("func(store.Repository) error")). + RunAndReturn(func(ctx context.Context, fn func(store.Repository) error) error { + return fn(repo) + }). + Once() + repo.EXPECT().CreateRelayRequest(ctx, chainIDEth, txHashLower).Return(nil).Once() + repo.EXPECT().CreatePacket(ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: txHashLower, + SourceTxTime: blockTime, + PacketSequenceNumber: 42, + PacketSourceClientID: "base-0", + PacketDestinationClientID: "ethereum-0", + PacketTimeoutTimestamp: time.Unix(1780000000, 0).UTC(), + }).Return(nil).Once() + + // ACT + statuses, err := service.Relay(ctx, chainIDEth, txHashUpper) + + // ASSERT: the freshly-pending status of the discovered packet is returned. + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, StatePending, statuses[0].State) + assert.Equal(t, uint64(42), statuses[0].SequenceNumber) + assert.Equal(t, "base-0", statuses[0].SourceClientID) + assert.Equal(t, TxInfo{TxHash: txHashLower, ChainID: chainIDEth}, statuses[0].SendTx) + }) + + t.Run("rejectsPacketFromUnconfiguredClient", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + events := []chains.PacketEvent{ + { + Height: 100, + Kind: chains.KindSendPacket, + Packet: chains.Packet{ + Sequence: 7, + SourceClient: "unknown-0", + DestClient: "ethereum-0", + }, + }, + } + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(events, nil).Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashUpper) + + // ASSERT: nothing persisted, whole request rejected + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, `client "unknown-0"`) + }) + + t.Run("errorsWhenTxHasNoPackets", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(nil, nil).Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashUpper) + + // ASSERT + require.ErrorIs(t, err, ErrNotFound) + require.ErrorContains(t, err, "no packet found") + }) + + t.Run("revertedTxIsInvalidInput", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT(). + TxPacketEvents(ctx, txHashBytes(t)). + Return(nil, errors.Wrap(chains.ErrTxReverted, "tx reverted")). + Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashUpper) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "reverted") + }) + + t.Run("unsupportedChain", func(t *testing.T) { + // ARRANGE + service := New(relayerConfig(), NewMockStore(t), NewMockChainClientManager(t)) + + // ACT + _, err := service.Relay(context.Background(), "999", txHashLower) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "unsupported chain") + }) + + t.Run("chainClientError", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + // config knows the chain but the manager has no client for it + clientManager.EXPECT().GetClient(chainIDEth).Return(nil, errors.New("no client")).Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashLower) + + // ASSERT + // a missing client is a server-side inconsistency, not a caller error + require.ErrorContains(t, err, "getting chain client") + require.NotErrorIs(t, err, ErrInvalidInput) + }) + + t.Run("extractionError", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + // nothing is recorded when extraction fails + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(nil, errors.New("rpc down")).Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashLower) + + // ASSERT + // an upstream RPC failure is neither bad input nor a missing packet + require.ErrorIs(t, err, ErrUpstream) + require.ErrorContains(t, err, "rpc down") + require.NotErrorIs(t, err, ErrInvalidInput) + require.NotErrorIs(t, err, ErrNotFound) + }) + + t.Run("receiptNotFoundIsNotFound", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), NewMockStore(t), clientManager) + + // a missing receipt (unknown tx) surfaces as ethereum.NotFound + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT(). + TxPacketEvents(ctx, txHashBytes(t)). + Return(nil, errors.Wrap(ethereum.NotFound, "getting receipt")). + Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashLower) + + // ASSERT + require.ErrorIs(t, err, ErrNotFound) + require.ErrorContains(t, err, "no packet found") + require.NotErrorIs(t, err, ErrUpstream) + }) + + t.Run("validation", func(t *testing.T) { + for _, tt := range []struct { + name string + chainID string + txHash string + }{ + {name: "empty chainID", chainID: "", txHash: txHashLower}, + {name: "empty txHash", chainID: chainIDEth, txHash: ""}, + {name: "not hex", chainID: chainIDEth, txHash: "0xnothex"}, + {name: "too short", chainID: chainIDEth, txHash: "0xdeadbeef"}, + {name: "missing prefix", chainID: chainIDEth, txHash: txHashLower[2:] + "00"}, + } { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + service := New(relayerConfig(), NewMockStore(t), NewMockChainClientManager(t)) + + // ACT + _, err := service.Relay(context.Background(), tt.chainID, tt.txHash) + + // ASSERT + require.ErrorIs(t, err, ErrInvalidInput) + }) + } + }) + + t.Run("storeError", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + st := NewMockStore(t) + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(relayerConfig(), st, clientManager) + + events := []chains.PacketEvent{ + { + Height: 100, + Kind: chains.KindSendPacket, + Packet: chains.Packet{ + Sequence: 42, + SourceClient: "base-0", + DestClient: "ethereum-0", + }, + }, + } + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(events, nil).Once() + st.EXPECT(). + ExecTx(ctx, mock.AnythingOfType("func(store.Repository) error")). + Return(errors.New("boom")). + Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashLower) + + // ASSERT + require.ErrorContains(t, err, "recording relay request") + require.NotErrorIs(t, err, ErrInvalidInput) + }) + + t.Run("sourceClientWithoutRouteRejected", func(t *testing.T) { + // ARRANGE: a config whose client has no routesToRelay entry. + cfg := relayerConfig() + cfg.Relayer.Routes = nil + ctx := context.Background() + client := chains.NewMockClient(t) + clientManager := NewMockChainClientManager(t) + service := New(cfg, NewMockStore(t), clientManager) + + events := []chains.PacketEvent{ + { + Height: 100, + Kind: chains.KindSendPacket, + Packet: chains.Packet{Sequence: 42, SourceClient: "base-0", DestClient: "ethereum-0"}, + }, + } + + clientManager.EXPECT().GetClient(chainIDEth).Return(client, nil).Once() + client.EXPECT().TxPacketEvents(ctx, txHashBytes(t)).Return(events, nil).Once() + + // ACT + _, err := service.Relay(ctx, chainIDEth, txHashLower) + + // ASSERT: rejected as invalid input; nothing persisted. + require.ErrorIs(t, err, ErrInvalidInput) + require.ErrorContains(t, err, "no configured relay route") + }) +} + +func TestStatus(t *testing.T) { + t.Run("notSubmitted", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + st.EXPECT().GetRelayRequest(ctx, chainIDEth, txHashLower).Return(nil, store.ErrNotFound).Once() + + // ACT + _, err := service.Status(ctx, chainIDEth, txHashLower) + + // ASSERT + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("submittedWithoutPackets", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + st.EXPECT().GetRelayRequest(ctx, chainIDEth, txHashLower).Return(&store.RelayRequest{ID: 1}, nil).Once() + st.EXPECT().ListPacketsBySourceTx(ctx, chainIDEth, txHashLower).Return(nil, nil).Once() + + // ACT + statuses, err := service.Status(ctx, chainIDEth, txHashLower) + + // ASSERT + require.NoError(t, err) + assert.Empty(t, statuses) + }) + + t.Run("mapsPackets", func(t *testing.T) { + // ARRANGE + ctx := context.Background() + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + recvTxHash := "0xrecv" + packets := []store.Packet{ + { + Status: store.RelayStatusDeliverRecvPacket, + PacketSequenceNumber: 42, + PacketSourceClientID: "base-0", + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: txHashLower, + RecvTxHash: &recvTxHash, + }, + { + Status: store.RelayStatusCompleteWithAck, + PacketSequenceNumber: 43, + PacketSourceClientID: "base-0", + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: txHashLower, + }, + } + + st.EXPECT().GetRelayRequest(ctx, chainIDEth, txHashLower).Return(&store.RelayRequest{ID: 1}, nil).Once() + st.EXPECT().ListPacketsBySourceTx(ctx, chainIDEth, txHashLower).Return(packets, nil).Once() + + // ACT + statuses, err := service.Status(ctx, chainIDEth, txHashUpper) + + // ASSERT + require.NoError(t, err) + require.Len(t, statuses, 2) + + first := statuses[0] + assert.Equal(t, StatePending, first.State) + assert.Equal(t, uint64(42), first.SequenceNumber) + assert.Equal(t, "base-0", first.SourceClientID) + assert.Equal(t, TxInfo{TxHash: txHashLower, ChainID: chainIDEth}, first.SendTx) + require.NotNil(t, first.RecvTx) + assert.Equal(t, TxInfo{TxHash: recvTxHash, ChainID: chainIDBase}, *first.RecvTx) + assert.Nil(t, first.AckTx) + assert.Nil(t, first.TimeoutTx) + + assert.Equal(t, StateComplete, statuses[1].State) + assert.Nil(t, statuses[1].RecvTx) + }) +} + +// statusPtr and derivation helpers for the deriveState tests. +func ackStatusPtr(s store.WriteAckStatus) *store.WriteAckStatus { return &s } + +func strPtr(s string) *string { return &s } + +func TestDeriveState(t *testing.T) { + timeoutAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + + t.Run("pendingStates", func(t *testing.T) { + pending := []store.RelayStatus{ + store.RelayStatusPending, + store.RelayStatusAwaitingSendFinality, + store.RelayStatusCheckRecvPacketDelivery, + store.RelayStatusGetRecvPacket, + store.RelayStatusDeliverRecvPacket, + store.RelayStatusWaitForWriteAck, + store.RelayStatusAwaitingWriteAckFinality, + store.RelayStatusCheckAckPacketDelivery, + store.RelayStatusGetAckPacket, + store.RelayStatusDeliverAckPacket, + store.RelayStatusAwaitingTimeoutFinality, + store.RelayStatusCheckTimeoutPacketDelivery, + store.RelayStatusGetTimeoutPacket, + store.RelayStatusDeliverTimeoutPacket, + } + for _, status := range pending { + state, effect, reason := deriveState(store.Packet{Status: status}) + assert.Equal(t, StatePending, state, string(status)) + assert.Empty(t, effect, string(status)) + assert.Empty(t, reason, string(status)) + } + }) + + t.Run("complete", func(t *testing.T) { + for _, status := range []store.RelayStatus{ + store.RelayStatusCompleteWithAck, + store.RelayStatusCompleteWithWriteAckSuccess, + } { + state, effect, reason := deriveState(store.Packet{ + Status: status, + RecvTxHash: strPtr("0xrecv"), + }) + assert.Equal(t, StateComplete, state, string(status)) + assert.Equal(t, "0xrecv", effect, string(status)) + assert.Empty(t, reason, string(status)) + } + }) + + t.Run("timedOut", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusCompleteWithTimeout, + TimeoutTxHash: strPtr("0xtimeout"), + PacketTimeoutTimestamp: timeoutAt, + }) + assert.Equal(t, StateTimedOut, state) + assert.Equal(t, "0xtimeout", effect) + assert.Contains(t, reason, "2026-01-02T03:04:05Z") + }) + + t.Run("failed carries no fabricated effect", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusFailed, + RecvTxHash: strPtr("0xrecv"), + }) + assert.Equal(t, StateFailed, state) + assert.Empty(t, effect) + assert.Equal(t, reasonFailed, reason) + }) + + t.Run("invariant failure surfaces a specific reason and no effect", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusFailedInvariant, + RecvTxHash: strPtr("0xrecv"), + AckTxHash: strPtr("0xack"), + }) + assert.Equal(t, StateFailed, state) + assert.Empty(t, effect, "no fabricated effect hash") + assert.Equal(t, reasonInvariant, reason) + assert.NotEqual(t, reasonFailed, reason, "distinct from an ordinary permanent failure") + }) + + t.Run("errorAck falls back to recv hash when ack absent", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusCompleteWithWriteAckError, + RecvTxHash: strPtr("0xrecv"), + WriteAckStatus: ackStatusPtr(store.WriteAckStatusError), + }) + assert.Equal(t, StateErrorAck, state) + assert.Equal(t, "0xrecv", effect) + assert.Equal(t, reasonErrorAck, reason) + }) + + t.Run("errorAck uses confirmed ack hash", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusCompleteWithAck, + RecvTxHash: strPtr("0xrecv"), + AckTxHash: strPtr("0xack"), + WriteAckStatus: ackStatusPtr(store.WriteAckStatusError), + }) + assert.Equal(t, StateErrorAck, state) + assert.Equal(t, "0xack", effect) + assert.Equal(t, reasonErrorAck, reason) + }) + + // The error_ack terminality invariant: an errored write ack that is not yet + // terminal on the source chain stays pending, with no premature effect. + t.Run("errored write ack not yet terminal stays pending", func(t *testing.T) { + state, effect, reason := deriveState(store.Packet{ + Status: store.RelayStatusAwaitingWriteAckFinality, + RecvTxHash: strPtr("0xrecv"), + WriteAckStatus: ackStatusPtr(store.WriteAckStatusError), + }) + assert.Equal(t, StatePending, state) + assert.Empty(t, effect) + assert.Empty(t, reason) + }) +} + +func TestListPacketStatuses(t *testing.T) { + ctx := context.Background() + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + chainID := chainIDEth + clientID := "base-0" + st.EXPECT(). + ListPackets(ctx, store.ListPacketsFilter{SourceChainID: &chainID, SourceClientID: &clientID}). + Return([]store.Packet{ + { + Status: store.RelayStatusCompleteWithTimeout, + SourceChainID: chainID, + DestinationChainID: chainIDBase, + PacketSequenceNumber: 3, + PacketSourceClientID: clientID, + TimeoutTxHash: strPtr("0xtimeout"), + PacketTimeoutTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }, + }, nil). + Once() + + statuses, err := service.ListPacketStatuses(ctx, StatusFilter{SourceChainID: chainID, SourceClientID: clientID}) + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, StateTimedOut, statuses[0].State) + assert.Equal(t, "0xtimeout", statuses[0].EffectTxHash) + assert.Equal(t, uint64(3), statuses[0].SequenceNumber) + assert.Equal(t, clientID, statuses[0].SourceClientID) +} + +func TestGetPacketStatus(t *testing.T) { + ctx := context.Background() + + t.Run("found", func(t *testing.T) { + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + key := store.PacketKey{SourceChainID: chainIDEth, PacketSequenceNumber: 7, PacketSourceClientID: "base-0"} + st.EXPECT().GetPacket(ctx, key).Return(&store.Packet{ + Status: store.RelayStatusCompleteWithAck, + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + PacketSequenceNumber: 7, + PacketSourceClientID: "base-0", + RecvTxHash: strPtr("0xrecv"), + }, nil).Once() + + status, ok, err := service.GetPacketStatus(ctx, chainIDEth, "base-0", 7) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, StateComplete, status.State) + assert.Equal(t, "0xrecv", status.EffectTxHash) + }) + + t.Run("not found", func(t *testing.T) { + st := NewMockStore(t) + service := New(relayerConfig(), st, NewMockChainClientManager(t)) + + st.EXPECT().GetPacket(ctx, mock.Anything).Return(nil, store.ErrNotFound).Once() + + _, ok, err := service.GetPacketStatus(ctx, chainIDEth, "base-0", 7) + require.NoError(t, err) + assert.False(t, ok) + }) +} diff --git a/link/internal/service/signer/evmtx.go b/link/internal/service/signer/evmtx.go new file mode 100644 index 000000000..8aad2aea4 --- /dev/null +++ b/link/internal/service/signer/evmtx.go @@ -0,0 +1,81 @@ +package signer + +import ( + "context" + "crypto/ecdsa" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/pkg/errors" +) + +// EVMAddress derives the EVM address (keccak of the uncompressed key) from a +// compressed secp256k1 public key. +func EVMAddress(pubKey []byte) (common.Address, error) { + pub, err := crypto.DecompressPubkey(pubKey) + if err != nil { + return common.Address{}, errors.Wrap(err, "decompressing secp256k1 public key") + } + + return crypto.PubkeyToAddress(*pub), nil +} + +// EVMTxSigner signs EVM transactions with a local secp256k1 key. It adapts a +// Signer to the evm.Signer contract (SignTx). Because an EVM transaction +// signature is over the RLP transaction digest (not the sha256(message) digest +// the generic Signer.Sign produces), it needs the raw ECDSA key: only local +// signers expose it via LocalKey.PrivateKey. Remote (KMS) tx signing is +// intentionally unimplemented — e2e and local operation use local keys, and a +// KMS path would need a dedicated SignHash RPC over the RLP digest. +type EVMTxSigner struct { + key *ecdsa.PrivateKey + from common.Address + chainID *big.Int +} + +// NewEVMTxSigner builds an EVMTxSigner from a configured signer and the numeric +// EVM chain id. The signer must be a local secp256k1 (ECDSA) key; anything else +// returns a clear error. +func NewEVMTxSigner(s Signer, chainID *big.Int) (*EVMTxSigner, error) { + if chainID == nil { + return nil, errors.New("evm chain id is required") + } + + if s.Type() != ECDSA { + return nil, errors.Errorf("evm tx signing requires an ECDSA signer, got %s", s.Type()) + } + + local, ok := s.(LocalKey) + if !ok { + return nil, errors.New( + "evm tx signing requires a local signer; remote (KMS) tx signing is not implemented") + } + + key, err := crypto.ToECDSA(local.PrivateKey()) + if err != nil { + return nil, errors.Wrap(err, "loading ecdsa private key for evm tx signing") + } + + return &EVMTxSigner{ + key: key, + from: crypto.PubkeyToAddress(key.PublicKey), + chainID: chainID, + }, nil +} + +// SignTx signs tx with the EIP-155/EIP-1559 signer for the configured chain id. +func (s *EVMTxSigner) SignTx(_ context.Context, tx *types.Transaction) (*types.Transaction, error) { + signed, err := types.SignTx(tx, types.LatestSignerForChainID(s.chainID), s.key) + if err != nil { + return nil, errors.Wrap(err, "signing evm transaction") + } + + return signed, nil +} + +// From returns the address transactions are signed from (derived from the key). +func (s *EVMTxSigner) From() common.Address { + return s.from +} diff --git a/link/internal/service/signer/evmtx_test.go b/link/internal/service/signer/evmtx_test.go new file mode 100644 index 000000000..3e17dbe57 --- /dev/null +++ b/link/internal/service/signer/evmtx_test.go @@ -0,0 +1,89 @@ +package signer_test + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/service/signer" +) + +func TestEVMTxSignerSignsAndRecovers(t *testing.T) { + local, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + + chainID := big.NewInt(1337) + txSigner, err := signer.NewEVMTxSigner(local, chainID) + require.NoError(t, err) + + to := common.HexToAddress("0x2222222222222222222222222222222222222222") + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &to, + }) + + signed, err := txSigner.SignTx(context.Background(), tx) + require.NoError(t, err) + + // The recovered sender must match the signer's From address. + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + assert.Equal(t, txSigner.From(), sender) +} + +func TestEVMTxSignerRejectsNilChainID(t *testing.T) { + local, err := signer.GenerateLocalSecp256k1Signer() + require.NoError(t, err) + + _, err = signer.NewEVMTxSigner(local, nil) + require.Error(t, err) +} + +type inconsistentLocalSigner struct { + privateKey []byte + publicKey []byte +} + +func (s inconsistentLocalSigner) Type() signer.KeyType { return signer.ECDSA } +func (s inconsistentLocalSigner) IsLocal() bool { return true } +func (s inconsistentLocalSigner) Sign(context.Context, []byte) ([]byte, error) { return nil, nil } +func (s inconsistentLocalSigner) PublicKey() []byte { return s.publicKey } +func (s inconsistentLocalSigner) PrivateKey() []byte { return s.privateKey } +func (s inconsistentLocalSigner) StoreToFile(string) error { return nil } + +func TestEVMTxSignerDerivesFromSigningKey(t *testing.T) { + signingKey, err := crypto.GenerateKey() + require.NoError(t, err) + otherKey, err := crypto.GenerateKey() + require.NoError(t, err) + + txSigner, err := signer.NewEVMTxSigner(inconsistentLocalSigner{ + privateKey: crypto.FromECDSA(signingKey), + publicKey: crypto.CompressPubkey(&otherKey.PublicKey), + }, big.NewInt(1)) + require.NoError(t, err) + assert.Equal(t, crypto.PubkeyToAddress(signingKey.PublicKey), txSigner.From()) +} + +type stubRemoteSigner struct{} + +func (stubRemoteSigner) Type() signer.KeyType { return signer.ECDSA } +func (stubRemoteSigner) IsLocal() bool { return false } +func (stubRemoteSigner) Sign(context.Context, []byte) ([]byte, error) { return nil, nil } +func (stubRemoteSigner) PublicKey() []byte { return nil } + +func TestEVMTxSignerRejectsRemote(t *testing.T) { + _, err := signer.NewEVMTxSigner(stubRemoteSigner{}, big.NewInt(1)) + require.Error(t, err) + assert.Contains(t, err.Error(), "remote") +} diff --git a/link/internal/service/signer/local.go b/link/internal/service/signer/local.go index a4bf5b06d..a1424b8aa 100644 --- a/link/internal/service/signer/local.go +++ b/link/internal/service/signer/local.go @@ -1,14 +1,12 @@ package signer import ( - "encoding/base64" - "encoding/json" - "os" "path/filepath" "github.com/pkg/errors" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/keyfile" ) // LocalKey is a key stored and used locally. @@ -18,11 +16,6 @@ type LocalKey interface { StoreToFile(path string) error } -type keyFile struct { - Type KeyType `json:"type"` - PrivateKey string `json:"privateKeyBase64"` -} - func KeyFilePath(homePath, keyName string) (string, error) { filename := keyName + ".json" path := filepath.Join(homePath, "/keys", filepath.Clean(filename)) @@ -40,30 +33,14 @@ func GenerateLocalKey(keyType KeyType) (LocalKey, error) { } } -// LoadKeyFromFile loads a local key from the path with multiple fallbacks -func LocalKeyFromFile(path ...string) (LocalKey, error) { - var ( - err error - key LocalKey - ) - - for _, tryPath := range path { - key, err = localKeyFromFile(tryPath) - if err == nil { - return key, nil - } - } - - return nil, err -} - -func localKeyFromFile(path string) (LocalKey, error) { - keyType, privateKey, err := loadKeyFromFile(path) +// LocalKeyFromFile loads a local key from the explicitly named path. +func LocalKeyFromFile(path string) (LocalKey, error) { + keyType, privateKey, err := keyfile.Load(path) if err != nil { return nil, err } - switch keyType { + switch KeyType(keyType) { case EDDSA: return NewLocalEd25519Signer(privateKey) case ECDSA: @@ -74,57 +51,5 @@ func localKeyFromFile(path string) (LocalKey, error) { } func storeKeyToFile(path string, keyType KeyType, privateKey []byte) error { - data := keyFile{ - Type: keyType, - PrivateKey: base64.StdEncoding.EncodeToString(privateKey), - } - - bz, err := json.Marshal(data) - if err != nil { - return err - } - - if dirErr := config.EnsureDirectory(path); dirErr != nil { - return dirErr - } - - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - if errors.Is(err, os.ErrExist) { - return errors.Errorf("file %s already exists", path) - } - - return err - } - - if _, err = file.Write(bz); err != nil { - _ = file.Close() - return err - } - - return file.Close() -} - -func loadKeyFromFile(path string) (KeyType, []byte, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", nil, err - } - - var key keyFile - if err = json.Unmarshal(data, &key); err != nil { - return "", nil, err - } - - _, err = ParseKeyType(string(key.Type)) - if err != nil { - return "", nil, err - } - - privateKey, err := base64.StdEncoding.DecodeString(key.PrivateKey) - if err != nil { - return "", nil, err - } - - return key.Type, privateKey, nil + return keyfile.Store(path, keyfile.Type(keyType), privateKey) } diff --git a/link/internal/service/signer/local_ed25519_test.go b/link/internal/service/signer/local_ed25519_test.go index a6038255a..e43a19b8c 100644 --- a/link/internal/service/signer/local_ed25519_test.go +++ b/link/internal/service/signer/local_ed25519_test.go @@ -69,9 +69,9 @@ func TestLocalEd25519Signer(t *testing.T) { ecdsaSigner, err := GenerateLocalSecp256k1Signer() require.NoError(t, err) - path := writeFileJSON(t, "ed25519.json", keyFile{ - Type: EDDSA, - PrivateKey: base64.StdEncoding.EncodeToString(ecdsaSigner.PrivateKey()), + path := writeFileJSON(t, "ed25519.json", map[string]string{ + "type": string(EDDSA), + "privateKeyBase64": base64.StdEncoding.EncodeToString(ecdsaSigner.PrivateKey()), }) // ACT @@ -88,9 +88,9 @@ func TestLocalEd25519Signer(t *testing.T) { }) t.Run("bytesMismatch", func(t *testing.T) { - path := writeFileJSON(t, "ed25519.json", keyFile{ - Type: EDDSA, - PrivateKey: base64.StdEncoding.EncodeToString([]byte("too short")), + path := writeFileJSON(t, "ed25519.json", map[string]string{ + "type": string(EDDSA), + "privateKeyBase64": base64.StdEncoding.EncodeToString([]byte("too short")), }) _, err := LocalKeyFromFile(path) diff --git a/link/internal/service/signer/local_secp256k1.go b/link/internal/service/signer/local_secp256k1.go index f6f89eea0..5e3cdcf6f 100644 --- a/link/internal/service/signer/local_secp256k1.go +++ b/link/internal/service/signer/local_secp256k1.go @@ -3,6 +3,7 @@ package signer import ( "context" "crypto/rand" + "crypto/sha256" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -48,9 +49,13 @@ func (s *LocalSecp256k1Signer) PrivateKey() []byte { return s.pk.Serialize() } -// Sign signs ECDSA *digest* (not message) -func (s *LocalSecp256k1Signer) Sign(ctx context.Context, digest []byte) ([]byte, error) { - return s.signer.Sign(ctx, digest) +// Sign produces a 65-byte recoverable secp256k1 signature (r||s||v, v∈{0,1}, +// low-S, RFC6979-deterministic) over sha256(message). The caller passes the raw +// message (e.g. attestation.SigningInput); hashing happens here so local and +// remote (KMS) signers share the same message-in contract. +func (s *LocalSecp256k1Signer) Sign(ctx context.Context, message []byte) ([]byte, error) { + digest := sha256.Sum256(message) + return s.signer.Sign(ctx, digest[:]) } func (s *LocalSecp256k1Signer) StoreToFile(path string) error { diff --git a/link/internal/service/signer/local_secp256k1_test.go b/link/internal/service/signer/local_secp256k1_test.go index b013362e0..b4401805d 100644 --- a/link/internal/service/signer/local_secp256k1_test.go +++ b/link/internal/service/signer/local_secp256k1_test.go @@ -14,6 +14,8 @@ import ( func TestLocalSecp256k1Signer(t *testing.T) { ctx := context.Background() + // The signer hashes the message with sha256 internally, so recovery is against + // sha256(message) regardless of the message length. message := []byte("hello secp256k1") digest := sha256.Sum256(message) @@ -23,7 +25,7 @@ func TestLocalSecp256k1Signer(t *testing.T) { require.NoError(t, err) // ACT - signature, err := signer.Sign(ctx, digest[:]) + signature, err := signer.Sign(ctx, message) // ASSERT require.NoError(t, err) @@ -60,16 +62,20 @@ func TestLocalSecp256k1Signer(t *testing.T) { }) }) - t.Run("digestRequired", func(t *testing.T) { + t.Run("arbitraryMessageLength", func(t *testing.T) { // ARRANGE signer, err := GenerateLocalSecp256k1Signer() require.NoError(t, err) + longMessage := make([]byte, 1024) + longDigest := sha256.Sum256(longMessage) + // ACT - _, err = signer.Sign(ctx, message) + signature, err := signer.Sign(ctx, longMessage) - // ASSERT - require.ErrorContains(t, err, "digest must be 32 bytes") + // ASSERT: any-length message is accepted and recovers against sha256(message) + require.NoError(t, err) + assertRecoverableSignature(t, signer.PublicKey(), longDigest[:], signature) }) t.Run("fileImportInvalidKey", func(t *testing.T) { @@ -78,9 +84,9 @@ func TestLocalSecp256k1Signer(t *testing.T) { ed25519Signer, err := GenerateLocalEd25519Signer() require.NoError(t, err) - path := writeFileJSON(t, "secp256k1.json", keyFile{ - Type: ECDSA, - PrivateKey: base64.StdEncoding.EncodeToString(ed25519Signer.PrivateKey()), + path := writeFileJSON(t, "secp256k1.json", map[string]string{ + "type": string(ECDSA), + "privateKeyBase64": base64.StdEncoding.EncodeToString(ed25519Signer.PrivateKey()), }) // ACT @@ -97,9 +103,9 @@ func TestLocalSecp256k1Signer(t *testing.T) { }) t.Run("bytesMismatch", func(t *testing.T) { - path := writeFileJSON(t, "secp256k1.json", keyFile{ - Type: ECDSA, - PrivateKey: base64.StdEncoding.EncodeToString([]byte("too short")), + path := writeFileJSON(t, "secp256k1.json", map[string]string{ + "type": string(ECDSA), + "privateKeyBase64": base64.StdEncoding.EncodeToString([]byte("too short")), }) _, err := LocalKeyFromFile(path) diff --git a/link/internal/service/signer/remote.go b/link/internal/service/signer/remote.go index 66d8d09ed..8249895e9 100644 --- a/link/internal/service/signer/remote.go +++ b/link/internal/service/signer/remote.go @@ -2,6 +2,7 @@ package signer import ( "context" + "crypto/sha256" "log/slog" "time" @@ -19,6 +20,11 @@ type RemoteSigner struct { key *signerservice.Key keyType KeyType + // conn is the gRPC connection owned by this signer when it was dialed via + // NewRemoteFromURL; it is nil when the client was injected (NewRemote), in + // which case the caller owns the transport and Close is a no-op. + conn *grpc.ClientConn + logger *slog.Logger } @@ -57,9 +63,22 @@ func NewRemoteFromURL(ctx context.Context, grpcURL, keyID string) (*RemoteSigner return nil, err } + // The signer now owns the connection and closes it via Close. + s.conn = grpcClient + return s, nil } +// Close releases the owned gRPC connection. It is a no-op when the client was +// injected (NewRemote) rather than dialed here. +func (r *RemoteSigner) Close() error { + if r.conn == nil { + return nil + } + + return r.conn.Close() +} + func (r *RemoteSigner) IsLocal() bool { return false } func (r *RemoteSigner) Type() KeyType { return r.keyType } @@ -70,9 +89,17 @@ func (r *RemoteSigner) PublicKey() []byte { func (r *RemoteSigner) Sign(ctx context.Context, message []byte) ([]byte, error) { r.logger.Debug("Sending sign request", "message", message) + // The KMS ECDSA scheme signs a prehashed 32-byte digest, mirroring the local + // secp256k1 signer's sha256(message) contract. Ed25519 signs the full message. + payload := message + if r.keyType == ECDSA { + digest := sha256.Sum256(message) + payload = digest[:] + } + resp, err := r.client.Sign(ctx, &signerservice.SignRequest{ KeyId: r.keyID, - Payload: bytesToPayload(message), + Payload: bytesToPayload(payload), }) if err != nil { return nil, errors.Wrap(err, "sign request failed") diff --git a/link/internal/service/signer/set_close_test.go b/link/internal/service/signer/set_close_test.go new file mode 100644 index 000000000..86e751c25 --- /dev/null +++ b/link/internal/service/signer/set_close_test.go @@ -0,0 +1,58 @@ +package signer + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// closableSigner is a Signer that records Close calls. The embedded Signer is nil +// (Set.Close only invokes Close), so no other method is exercised. +type closableSigner struct { + Signer + closed bool + err error +} + +func (c *closableSigner) Close() error { + c.closed = true + return c.err +} + +// TestSetCloseClosesClosableSigners verifies Set.Close closes signers that own a +// transport (remote/KMS) and skips those that do not (local keys). +func TestSetCloseClosesClosableSigners(t *testing.T) { + local, err := GenerateLocalSecp256k1Signer() + require.NoError(t, err) + + remote := &closableSigner{} + + set := NewSet() + set.Set("remote", remote) + set.Set("local", local) // no Close method -> skipped, must not panic + + require.NoError(t, set.Close()) + assert.True(t, remote.closed, "remote signer not closed") +} + +// TestSetCloseJoinsErrors verifies a failing signer close is surfaced, and that +// errors.Is still finds the underlying per-signer error through the joined, +// contextually-wrapped result. +func TestSetCloseJoinsErrors(t *testing.T) { + errBoom := errors.New("boom") + set := NewSet() + set.Set("bad", &closableSigner{err: errBoom}) + + err := set.Close() + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.ErrorIs(t, err, errBoom, "joined error must still satisfy errors.Is for the underlying per-signer error") +} + +// TestRemoteSignerCloseNilConnIsNoop verifies Close tolerates an injected client +// (no owned connection). +func TestRemoteSignerCloseNilConnIsNoop(t *testing.T) { + require.NoError(t, (&RemoteSigner{}).Close()) +} diff --git a/link/internal/service/signer/signer.go b/link/internal/service/signer/signer.go index 59de1c077..201c29dbf 100644 --- a/link/internal/service/signer/signer.go +++ b/link/internal/service/signer/signer.go @@ -6,12 +6,22 @@ import ( "github.com/pkg/errors" "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/keyfile" + + stderrors "errors" ) // KeyType key type supported by local Signer type KeyType string -// Signer represents signer that can either sign digests or messages. +// Signer represents a signer over a raw message. +// +// The contract is message-in, not digest-in: implementations apply their scheme's +// hashing internally. For secp256k1 (ECDSA) this means the signature is produced +// over sha256(message) and returned as 65 recoverable bytes r||s||v with v∈{0,1}; +// remote (KMS) ECDSA signers hash locally and forward the 32-byte digest, while +// Ed25519 forwards the full message. Callers must therefore pass the unhashed +// message (e.g. attestation.SigningInput). type Signer interface { Type() KeyType IsLocal() bool @@ -27,8 +37,8 @@ type Set struct { // KeyType & SignerType enums const ( - EDDSA KeyType = "eddsa" - ECDSA KeyType = "ecdsa" + EDDSA KeyType = KeyType(keyfile.EDDSA) + ECDSA KeyType = KeyType(keyfile.ECDSA) ) func NewSet() *Set { @@ -46,6 +56,26 @@ func (s *Set) Get(alias string) (Signer, bool) { return signer, ok } +// Close releases every signer that owns a closable transport (remote/KMS signers +// hold a gRPC connection; local signers do not). Per-signer errors are wrapped +// with context via github.com/pkg/errors (whose Unwrap chain works with stdlib +// errors.Is) and joined with the standard library's errors.Join, so errors.Is +// still finds a sentinel/underlying per-signer error through the returned error. +func (s *Set) Close() error { + var errs []error + for alias, signer := range s.set { + closer, ok := signer.(interface{ Close() error }) + if !ok { + continue + } + if err := closer.Close(); err != nil { + errs = append(errs, errors.Wrapf(err, "closing signer %q", alias)) + } + } + + return stderrors.Join(errs...) +} + func NewSignerFromConfig(ctx context.Context, cfg config.SignerConfig) (signer Signer, alias string, err error) { switch cfg.Type { case config.SignerLocal: @@ -54,7 +84,7 @@ func NewSignerFromConfig(ctx context.Context, cfg config.SignerConfig) (signer S return nil, "", errors.Wrap(err, "expand local signer file") } - s, err := LocalKeyFromFile(config.KeyFileFallbacks(path)...) + s, err := LocalKeyFromFile(path) return s, cfg.Alias, err case config.SignerRemote: @@ -70,9 +100,6 @@ func NewSignerFromConfig(ctx context.Context, cfg config.SignerConfig) (signer S } func ParseKeyType(raw string) (KeyType, error) { - if raw != string(EDDSA) && raw != string(ECDSA) { - return "", errors.Errorf("invalid key type: %s", raw) - } - - return KeyType(raw), nil + parsed, err := keyfile.ParseType(raw) + return KeyType(parsed), err } diff --git a/link/internal/service/signer/signer_test.go b/link/internal/service/signer/signer_test.go index ef298403f..626df3b3d 100644 --- a/link/internal/service/signer/signer_test.go +++ b/link/internal/service/signer/signer_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - "github.com/cosmos/kms/gen/signerservice" "github.com/stretchr/testify/require" "github.com/cosmos/ibc/link/internal/config" @@ -14,34 +13,12 @@ import ( func TestSignerSet(t *testing.T) { t.Run("setGet", func(t *testing.T) { // ARRANGE - ctx := context.Background() signerSet := NewSet() signerA, err := GenerateLocalEd25519Signer() require.NoError(t, err) - signerB, err := GenerateLocalSecp256k1Signer() - require.NoError(t, err) - - signerC, err := GenerateLocalEd25519Signer() - require.NoError(t, err) - - remoteTS := newRemoteTestSuite(t) - remoteTS.OnKeyRequest("remote-key", &signerservice.GetKeyResponse{ - Key: &signerservice.Key{ - Id: "remote-key", - Pubkey: []byte("remote-public-key"), - Scheme: signerservice.SignatureScheme_ED25519, - }, - }, nil) - - remoteSignerD, err := NewRemote(ctx, remoteTS.Client, "remote-key") - require.NoError(t, err) - signerSet.Set("A", signerA) - signerSet.Set("B", signerB) - signerSet.Set("C", signerC) - signerSet.Set("D", remoteSignerD) // ACT actualSigner, found := signerSet.Get("A") @@ -75,3 +52,18 @@ func TestNewSignerFromConfigExpandsHome(t *testing.T) { require.Equal(t, "local", alias) require.Equal(t, key.PublicKey(), loadedSigner.PublicKey()) } + +func TestNewSignerFromConfigRequiresExactFilePath(t *testing.T) { + key, err := GenerateLocalEd25519Signer() + require.NoError(t, err) + + dir := t.TempDir() + require.NoError(t, key.StoreToFile(filepath.Join(dir, "signer.json"))) + + _, _, err = NewSignerFromConfig(context.Background(), config.SignerConfig{ + Alias: "local", + Type: config.SignerLocal, + File: filepath.Join(dir, "signer"), + }) + require.Error(t, err) +} diff --git a/link/internal/service/status/prober.go b/link/internal/service/status/prober.go new file mode 100644 index 000000000..e488b6a32 --- /dev/null +++ b/link/internal/service/status/prober.go @@ -0,0 +1,157 @@ +package status + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/pkg/evmrevert" + "github.com/cosmos/ibc/link/internal/service/attestor" + + ics26router "github.com/cosmos/solidity-ibc-eureka/packages/go-abigen/ics26router" +) + +// Default probe timeouts. Chains get a slightly longer budget than attestors +// because a cold RPC dial plus two eth_call round-trips can be involved. +const ( + DefaultChainTimeout = 5 * time.Second + DefaultAttestorTimeout = 3 * time.Second +) + +// EVMProber is the live ChainProber: it dials an EVM RPC, checks the router has +// code, and checks the configured client id is registered on the router. Every +// failure mode degrades to an EndpointProbe rather than an error, so an +// unreachable chain never fails the status command. +type EVMProber struct { + Timeout time.Duration +} + +var _ ChainProber = EVMProber{} + +func (p EVMProber) ProbeEndpoint(ctx context.Context, ep Endpoint) EndpointProbe { + timeout := p.Timeout + if timeout <= 0 { + timeout = DefaultChainTimeout + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + eth, err := ethclient.DialContext(ctx, ep.RPC) + if err != nil { + return EndpointProbe{Err: err} + } + defer eth.Close() + + // Liveness plus identity: a cheap call that forces the transport to connect and + // reveals which network the RPC actually serves. + chainID, err := eth.ChainID(ctx) + if err != nil { + return EndpointProbe{Err: err} + } + + // The endpoint carries the chain id it is expected to serve. A numeric expected + // id that disagrees with the RPC-reported id means this RPC is pointed at a + // different network: reachable, but wrong — its router/client facts would + // describe the wrong chain, so report the mismatch and probe no further. A + // non-numeric expected id is a label (not an on-chain id) and is not comparable, + // mirroring the daemon's startup identity check. + if expected, ok := new(big.Int).SetString(ep.ChainID, 10); ok && expected.Cmp(chainID) != 0 { + return EndpointProbe{ + Reachable: true, + WrongNetwork: true, + Err: errors.Errorf("RPC serves chain id %s, expected %s", chainID, ep.ChainID), + } + } + + probe := EndpointProbe{Reachable: true} + + if !common.IsHexAddress(ep.Router) { + // Reachable, but there is no router address to verify against. + return probe + } + + router := common.HexToAddress(ep.Router) + + code, err := eth.CodeAt(ctx, router, nil) + if err != nil { + probe.Err = err + + return probe + } + + hasCode := len(code) > 0 + probe.RouterHasCode = &hasCode + + // Client registration is only meaningful once the router contract exists. + if !hasCode { + return probe + } + + registered, err := clientRegistered(ctx, eth, router, ep.ClientID) + if err != nil { + probe.Err = err + + return probe + } + + probe.ClientRegistered = ®istered + + return probe +} + +// clientRegistered reports whether clientID is registered on the router: GetClient +// succeeds when registered, and reverts with IBCClientNotFound (matched via the +// shared evmrevert helper, as deploy's re-runnability checks do) when it is not. +func clientRegistered( + ctx context.Context, + caller bind.ContractCaller, + router common.Address, + clientID string, +) (bool, error) { + contract, err := ics26router.NewContractCaller(router, caller) + if err != nil { + return false, err + } + + if _, err := contract.GetClient(&bind.CallOpts{Context: ctx}, clientID); err != nil { + if evmrevert.HasSelector(err, "IBCClientNotFound(string)") { + return false, nil + } + + return false, err + } + + return true, nil +} + +// RemoteAttestorProber is the live AttestorProber: it dials a remote attestor's +// gRPC endpoint and asks for its latest attestable height. +type RemoteAttestorProber struct { + Timeout time.Duration +} + +var _ AttestorProber = RemoteAttestorProber{} + +func (p RemoteAttestorProber) ProbeHeight(ctx context.Context, name, grpcURL string) (uint64, error) { + timeout := p.Timeout + if timeout <= 0 { + timeout = DefaultAttestorTimeout + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + return dialRemoteHeight(ctx, name, grpcURL) +} + +// dialRemoteHeight queries a remote attestor's latest attestable height over its +// connect gRPC endpoint, reusing the relayer's remote attestor client. +func dialRemoteHeight(ctx context.Context, name, grpcURL string) (uint64, error) { + return attestor.NewRemoteFromURL(name, grpcURL).LatestAttestableHeight(ctx) +} diff --git a/link/internal/service/status/status.go b/link/internal/service/status/status.go new file mode 100644 index 000000000..a175484c8 --- /dev/null +++ b/link/internal/service/status/status.go @@ -0,0 +1,514 @@ +// Package status assembles the operator status report served by `ibc status`. +// +// It reads the shared relayer database directly (no running daemon required) and +// probes each configured route's chains and attestor set, degrading unreachable +// endpoints to reported state rather than command failure. The assembly is pure +// with respect to I/O: all network and database access enters through the +// PacketStore, ChainProber, and AttestorProber seams so it can be unit-tested +// against a seeded store and fake probes. +package status + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/relayer" + "github.com/cosmos/ibc/link/internal/store" +) + +// DefaultStuckAfter is the age past which a non-terminal packet is reported as +// stuck when the caller does not override it. +const DefaultStuckAfter = 10 * time.Minute + +// Endpoint state values. +const ( + // EndpointReachable the RPC answered a liveness call and serves the expected chain. + EndpointReachable = "reachable" + // EndpointUnreachable the RPC could not be dialed or did not answer. + EndpointUnreachable = "unreachable" + // EndpointWrongNetwork the RPC answered but reported a different chain id than + // the endpoint expects, so it is serving the wrong network. + EndpointWrongNetwork = "wrong-network" + // EndpointUnconfigured no usable chain RPC is configured for the endpoint. + EndpointUnconfigured = "unconfigured" +) + +// Attestor type labels reported in the quorum section. +const ( + attestorTypeLocal = "local" + attestorTypeRemote = "remote" +) + +// Quorum leg state values. A route's two delivery legs draw from different +// attestor sets, so each is evaluated independently. +// +// Local (in-process) attestors cannot be probed by the CLI, so the state is +// three-valued: it distinguishes a leg that is provably healthy from one that is +// provably broken from one the CLI simply cannot decide. +const ( + // QuorumOK the reachable remote attestors alone meet the threshold, so the + // leg has quorum regardless of any unprobeable local members. + QuorumOK = "ok" + // QuorumUnknown the reachable remotes alone fall short of the threshold, but + // counting every unprobeable local member as up could reach it. The CLI + // cannot probe local members, so it cannot decide. + QuorumUnknown = "unknown" + // QuorumNo even counting every local member as up, the reachable set cannot + // reach the threshold: the leg is provably without quorum. + QuorumNo = "no" +) + +// localNotProbedNote explains why a local attestor carries no probed height. +const localNotProbedNote = "local (in-process; not probed)" + +// PacketStore is the read seam the assembly needs from the relayer store. The +// concrete *store.SqliteDB / *store.PostgresDB satisfy it. +type PacketStore interface { + ListPackets(ctx context.Context, filter store.ListPacketsFilter) ([]store.Packet, error) + CountPacketSubmissions(ctx context.Context, filter store.ListPacketsFilter) (map[int64]int64, error) +} + +// Endpoint identifies a single chain endpoint to probe. +type Endpoint struct { + ChainID string + RPC string + Router string + ClientID string +} + +// EndpointProbe is the low-level result a ChainProber returns: reachability plus +// the two optional facts (router code present, client registered). A nil pointer +// means the fact could not be determined (unreachable, wrong network, or a +// downstream query failed); Err carries the reason. WrongNetwork is set when the +// RPC answered but reported a different chain id than expected, in which case the +// on-chain facts are omitted because they would describe the wrong chain. +type EndpointProbe struct { + Reachable bool + WrongNetwork bool + RouterHasCode *bool + ClientRegistered *bool + Err error +} + +// ChainProber probes a single chain endpoint. Implementations must not fail the +// caller on an unreachable RPC; they report it via EndpointProbe instead. +type ChainProber interface { + ProbeEndpoint(ctx context.Context, ep Endpoint) EndpointProbe +} + +// AttestorProber probes a single remote attestor's latest attestable height. +// Reachability is (err == nil). +type AttestorProber interface { + ProbeHeight(ctx context.Context, name, grpcURL string) (uint64, error) +} + +// Options tunes an assembly run. +type Options struct { + // StuckAfter is the age past which a non-terminal packet is listed as stuck. + // Zero uses DefaultStuckAfter. + StuckAfter time.Duration + + // Now is the reference time for age computations. Zero uses time.Now(). + Now time.Time + + // Alias restricts the report to this route source-client alias. Empty includes + // every configured route (routesToRelay). + Alias string +} + +// Report is the top-level `ibc status` JSON document. +type Report struct { + GeneratedAt time.Time `json:"generatedAt"` + StuckAfter string `json:"stuckAfter"` + Routes []RouteStatus `json:"routes"` +} + +// RouteStatus is a single relay route's status. +type RouteStatus struct { + Alias string `json:"alias"` + ChainID string `json:"chainId"` + ClientID string `json:"clientId"` + Counterparty Counterparty `json:"counterparty"` + Connection ConnectionStatus `json:"connection"` + Packets PacketsStatus `json:"packets"` + Quorum QuorumStatus `json:"quorum"` +} + +// Counterparty identifies the route's counterparty client. +type Counterparty struct { + ChainID string `json:"chainId"` + ClientID string `json:"clientId"` +} + +// ConnectionStatus reports both chains backing a route. +type ConnectionStatus struct { + Source EndpointStatus `json:"source"` + Counterparty EndpointStatus `json:"counterparty"` +} + +// EndpointStatus is a single chain endpoint's reachability and on-chain wiring. +type EndpointStatus struct { + ChainID string `json:"chainId"` + ClientID string `json:"clientId"` + State string `json:"state"` + RouterHasCode *bool `json:"routerHasCode,omitempty"` + ClientRegistered *bool `json:"clientRegistered,omitempty"` + Error string `json:"error,omitempty"` +} + +// PacketsStatus aggregates a route's tracked packets. +type PacketsStatus struct { + Total int `json:"total"` + ByState map[string]int `json:"byState"` + OldestPendingAgeSeconds *int64 `json:"oldestPendingAgeSeconds,omitempty"` + Stuck []StuckPacket `json:"stuck"` +} + +// StuckPacket is a non-terminal packet older than the stuck threshold. +type StuckPacket struct { + PacketID string `json:"packetId"` + Sequence uint64 `json:"sequence"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + AgeSeconds int64 `json:"ageSeconds"` + Attempts int64 `json:"attempts"` +} + +// QuorumStatus reports both of a route's delivery legs' attestor quorums. The +// legs cross: receive delivery is proven by the mirror (counterparty) client's +// attestor set, while ack/timeout delivery is proven by the source client's set. +// A route can therefore have quorum on one leg and not the other, so they are +// reported separately rather than collapsed into a single verdict. +type QuorumStatus struct { + Recv QuorumLeg `json:"recv"` + AckTimeout QuorumLeg `json:"ackTimeout"` +} + +// QuorumLeg reports one delivery leg's attestor set liveness against its +// threshold. State is one of QuorumOK, QuorumUnknown, or QuorumNo (see those +// constants); ReachableCount counts only remote attestors the CLI could probe. +type QuorumLeg struct { + Threshold int `json:"threshold"` + ReachableCount int `json:"reachableCount"` + State string `json:"state"` + Attestors []AttestorStatus `json:"attestors"` +} + +// AttestorStatus is a single attestor's probe result. Local attestors are not +// probed by the CLI (they need the in-process service): they carry Probed=false +// and a note, and are never counted toward ReachableCount. +type AttestorStatus struct { + Name string `json:"name"` + Type string `json:"type"` + Probed bool `json:"probed"` + Reachable bool `json:"reachable"` + Height *uint64 `json:"height,omitempty"` + Note string `json:"note,omitempty"` + Error string `json:"error,omitempty"` +} + +// Assemble builds the status report for the configured routes. It returns an +// error only when a store query fails; unreachable chains and attestors are +// reported as data. +func Assemble( + ctx context.Context, + cfg config.Config, + st PacketStore, + chain ChainProber, + att AttestorProber, + opts Options, +) (Report, error) { + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + + stuckAfter := opts.StuckAfter + if stuckAfter <= 0 { + stuckAfter = DefaultStuckAfter + } + + report := Report{ + GeneratedAt: now.UTC(), + StuckAfter: stuckAfter.String(), + Routes: []RouteStatus{}, + } + + for _, route := range cfg.Relayer.Routes { + if opts.Alias != "" && route.SourceClient != opts.Alias { + continue + } + + client, ok := cfg.Relayer.ClientByAlias(route.SourceClient) + if !ok { + // A route pointing at an unknown client is a config error the daemon + // would reject; report it as an identity-only route rather than crash. + report.Routes = append(report.Routes, RouteStatus{ + Alias: route.SourceClient, + Packets: PacketsStatus{ByState: map[string]int{}, Stuck: []StuckPacket{}}, + Quorum: QuorumStatus{Recv: emptyQuorumLeg(), AckTimeout: emptyQuorumLeg()}, + }) + + continue + } + + rs, err := assembleRoute(ctx, cfg, st, chain, att, client, now, stuckAfter) + if err != nil { + return Report{}, err + } + + report.Routes = append(report.Routes, rs) + } + + return report, nil +} + +func assembleRoute( + ctx context.Context, + cfg config.Config, + st PacketStore, + chain ChainProber, + att AttestorProber, + client config.ClientConfig, + now time.Time, + stuckAfter time.Duration, +) (RouteStatus, error) { + packets, err := assemblePackets(ctx, st, client, now, stuckAfter) + if err != nil { + return RouteStatus{}, err + } + + return RouteStatus{ + Alias: client.Alias, + ChainID: client.ChainID, + ClientID: client.ClientID, + Counterparty: Counterparty{ + ChainID: client.CounterpartyChainID, + ClientID: client.CounterpartyClientID, + }, + Connection: ConnectionStatus{ + Source: probeEndpoint(ctx, chain, cfg, client.ChainID, client.ClientID), + Counterparty: probeEndpoint(ctx, chain, cfg, client.CounterpartyChainID, client.CounterpartyClientID), + }, + Packets: packets, + Quorum: assembleQuorum(ctx, att, cfg, client), + }, nil +} + +// probeEndpoint resolves the chain's RPC/router from config and probes it. An +// unconfigured or RPC-less chain reports "unconfigured" without a probe. +func probeEndpoint( + ctx context.Context, + prober ChainProber, + cfg config.Config, + chainID, clientID string, +) EndpointStatus { + es := EndpointStatus{ChainID: chainID, ClientID: clientID, State: EndpointUnconfigured} + + chainCfg, ok := cfg.Chain(chainID) + if !ok || chainCfg.EVM == nil || chainCfg.EVM.RPC == "" { + return es + } + + probe := prober.ProbeEndpoint(ctx, Endpoint{ + ChainID: chainID, + RPC: chainCfg.EVM.RPC, + Router: chainCfg.EVM.ICS26Router, + ClientID: clientID, + }) + + switch { + case probe.WrongNetwork: + es.State = EndpointWrongNetwork + case probe.Reachable: + es.State = EndpointReachable + default: + es.State = EndpointUnreachable + } + + es.RouterHasCode = probe.RouterHasCode + es.ClientRegistered = probe.ClientRegistered + if probe.Err != nil { + es.Error = probe.Err.Error() + } + + return es +} + +func assemblePackets( + ctx context.Context, + st PacketStore, + client config.ClientConfig, + now time.Time, + stuckAfter time.Duration, +) (PacketsStatus, error) { + filter := store.ListPacketsFilter{ + SourceChainID: strPtr(client.ChainID), + SourceClientID: strPtr(client.ClientID), + } + + packets, err := st.ListPackets(ctx, filter) + if err != nil { + return PacketsStatus{}, errors.Wrapf(err, "listing packets for route %q", client.Alias) + } + + attempts, err := st.CountPacketSubmissions(ctx, filter) + if err != nil { + return PacketsStatus{}, errors.Wrapf(err, "counting submissions for route %q", client.Alias) + } + + out := PacketsStatus{ + Total: len(packets), + ByState: map[string]int{}, + Stuck: []StuckPacket{}, + } + + var oldestPending *time.Time + + for i := range packets { + p := packets[i] + derived := relayer.DeriveStatus(p) + state := derived.State.String() + out.ByState[state]++ + + if derived.State.Terminal() { + continue + } + + age := now.Sub(p.SourceTxTime) + + if oldestPending == nil || p.SourceTxTime.Before(*oldestPending) { + t := p.SourceTxTime + oldestPending = &t + } + + if age >= stuckAfter { + out.Stuck = append(out.Stuck, StuckPacket{ + PacketID: packetID(client.Alias, p.PacketSequenceNumber), + Sequence: p.PacketSequenceNumber, + State: state, + Reason: derived.Reason, + AgeSeconds: int64(age.Seconds()), + Attempts: attempts[p.ID], + }) + } + } + + if oldestPending != nil { + age := int64(now.Sub(*oldestPending).Seconds()) + out.OldestPendingAgeSeconds = &age + } + + return out, nil +} + +// assembleQuorum reports both delivery legs' quorums for a route. The legs cross +// (mirroring engine.buildRoutes): receive delivery is proven by the mirror +// (counterparty) client's attestor set, ack/timeout delivery by the source +// client's set. Config validation guarantees the mirror entry and its attestor +// set exist, but a missing mirror degrades to an "unknown" recv leg rather than +// crashing the report. +func assembleQuorum( + ctx context.Context, + prober AttestorProber, + cfg config.Config, + client config.ClientConfig, +) QuorumStatus { + var recvSet *config.AttestorSetConfig + if mirror, ok := cfg.Relayer.Client(client.CounterpartyChainID, client.CounterpartyClientID); ok { + recvSet = mirror.AttestorSet + } + + return QuorumStatus{ + Recv: assembleQuorumLeg(ctx, prober, recvSet), + AckTimeout: assembleQuorumLeg(ctx, prober, client.AttestorSet), + } +} + +// assembleQuorumLeg probes one attestor set and derives its three-valued state. +// A nil set (mirror missing, or set unconfigured) yields an empty "unknown" leg. +func assembleQuorumLeg(ctx context.Context, prober AttestorProber, set *config.AttestorSetConfig) QuorumLeg { + if set == nil { + return emptyQuorumLeg() + } + + leg := QuorumLeg{ + Threshold: set.Threshold, + Attestors: make([]AttestorStatus, 0, len(set.Attestors)), + } + + localCount := 0 + + for _, entry := range set.Attestors { + as := AttestorStatus{Name: entry.Name, Type: string(entry.Type)} + + switch entry.Type { + case config.AttestorTypeLocal: + // Local members run in-process; the CLI has no way to reach them, so + // they are reported unprobed and counted only as potential members. + as.Type = attestorTypeLocal + as.Note = localNotProbedNote + localCount++ + + case config.AttestorTypeRemote: + as.Type = attestorTypeRemote + as.Probed = true + height, err := prober.ProbeHeight(ctx, entry.Name, entry.GRPC) + if err != nil { + as.Error = err.Error() + } else { + as.Reachable = true + h := height + as.Height = &h + leg.ReachableCount++ + } + + default: + as.Error = fmt.Sprintf("unknown attestor type %q", entry.Type) + } + + leg.Attestors = append(leg.Attestors, as) + } + + leg.State = quorumState(leg.ReachableCount, localCount, set.Threshold) + + return leg +} + +// quorumState maps the probed remote reachable count, the (unprobeable) local +// member count, and the threshold to a three-valued verdict. See the QuorumOK, +// QuorumUnknown, and QuorumNo constants. +func quorumState(reachableRemotes, localMembers, threshold int) string { + switch { + case reachableRemotes >= threshold: + return QuorumOK + case reachableRemotes+localMembers >= threshold: + return QuorumUnknown + default: + return QuorumNo + } +} + +// emptyQuorumLeg is the leg reported when no attestor set is available to +// evaluate: its requirement is unknown, so it cannot be OK or provably No. +func emptyQuorumLeg() QuorumLeg { + return QuorumLeg{State: QuorumUnknown, Attestors: []AttestorStatus{}} +} + +// packetID formats a packet identifier as "-", matching +// the relayer HTTP /status wire form. +func packetID(alias string, seq uint64) string { + return fmt.Sprintf("%s-%d", alias, seq) +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + + return &s +} diff --git a/link/internal/service/status/status_test.go b/link/internal/service/status/status_test.go new file mode 100644 index 000000000..531c1ffa7 --- /dev/null +++ b/link/internal/service/status/status_test.go @@ -0,0 +1,552 @@ +package status_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/config" + "github.com/cosmos/ibc/link/internal/service/status" + "github.com/cosmos/ibc/link/internal/store" +) + +// fakeChainProber returns a canned probe per chain id, standing in for a live +// EVM RPC dial at the ChainProber seam. +type fakeChainProber struct { + byChain map[string]status.EndpointProbe +} + +func (f fakeChainProber) ProbeEndpoint(_ context.Context, ep status.Endpoint) status.EndpointProbe { + return f.byChain[ep.ChainID] +} + +// fakeAttestorProber returns a canned height/err per attestor name. +type fakeAttestorProber struct { + byName map[string]heightResult +} + +type heightResult struct { + height uint64 + err error +} + +func (f fakeAttestorProber) ProbeHeight(_ context.Context, name, _ string) (uint64, error) { + r := f.byName[name] + + return r.height, r.err +} + +const ( + chainSource = "1" + chainDest = "8453" + clientSrc = "ethereum-0" + clientDst = "base-0" + routeAlias = "eth-to-base" +) + +func boolPtr(b bool) *bool { return &b } + +// testConfig builds a single-route config plus the route's mirror client. The +// source client (routeAlias) on chainSource has a threshold-2 set of one local + +// two remote members — this backs the ack/timeout leg. Its mirror client on +// chainDest has a threshold-1 single-remote set — this backs the recv leg. The +// two legs draw from different sets so a test can pin each independently. +func testConfig() config.Config { + return config.Config{ + Chains: []config.ChainConfig{ + {ChainID: chainSource, EVM: &config.EVMChainConfig{RPC: "http://src:8545", ICS26Router: "0x00000000000000000000000000000000000000a1"}}, + {ChainID: chainDest, EVM: &config.EVMChainConfig{RPC: "http://dst:8545", ICS26Router: "0x00000000000000000000000000000000000000b2"}}, + }, + Relayer: config.RelayerConfig{ + Clients: []config.ClientConfig{ + { + Alias: routeAlias, + ClientID: clientSrc, + ChainID: chainSource, + CounterpartyChainID: chainDest, + CounterpartyClientID: clientDst, + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 2, + Attestors: []config.AttestorEntry{ + {Name: "att-local", Type: config.AttestorTypeLocal}, + {Name: "att-remote-1", Type: config.AttestorTypeRemote, GRPC: "http://att1:9090"}, + {Name: "att-remote-2", Type: config.AttestorTypeRemote, GRPC: "http://att2:9090"}, + }, + }, + }, + { + Alias: "base-to-eth", + ClientID: clientDst, + ChainID: chainDest, + CounterpartyChainID: chainSource, + CounterpartyClientID: clientSrc, + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{ + {Name: "att-remote-3", Type: config.AttestorTypeRemote, GRPC: "http://att3:9090"}, + }, + }, + }, + }, + Routes: []config.RouteConfig{{SourceClient: routeAlias}}, + }, + } +} + +func newSeededStore(t *testing.T) *store.SqliteDB { + t.Helper() + + db, err := store.NewSqliteInMemory() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.MigrateUp() + require.NoError(t, err) + + return db +} + +// seedPacket inserts a packet on the route source and advances it to status, +// returning its id. +func seedPacket(t *testing.T, db *store.SqliteDB, seq uint64, sentAt time.Time, s store.RelayStatus) int64 { + t.Helper() + ctx := context.Background() + + require.NoError(t, db.CreatePacket(ctx, store.CreatePacket{ + Status: store.RelayStatusPending, + SourceChainID: chainSource, + DestinationChainID: chainDest, + SourceTxHash: "0xsource", + SourceTxTime: sentAt, + PacketSequenceNumber: seq, + PacketSourceClientID: clientSrc, + PacketDestinationClientID: clientDst, + PacketTimeoutTimestamp: sentAt.Add(time.Hour), + })) + + key := store.PacketKey{SourceChainID: chainSource, PacketSequenceNumber: seq, PacketSourceClientID: clientSrc} + if s != store.RelayStatusPending { + require.NoError(t, db.UpdatePacketStatus(ctx, key, s)) + } + + p, err := db.GetPacket(ctx, key) + require.NoError(t, err) + + return p.ID +} + +func linkSubmission(t *testing.T, db *store.SqliteDB, packetID int64, txHash string) { + t.Helper() + ctx := context.Background() + + id, err := db.CreateTxSubmission(ctx, store.CreateTxSubmission{ + TxHash: txHash, + ChainID: chainSource, + TxType: store.TxTypeRecvPacket, + RelayerAddress: "relayer", + SubmittedAt: time.Now().UTC(), + }) + require.NoError(t, err) + require.NoError(t, db.LinkPacketTxSubmission(ctx, packetID, id)) +} + +func TestAssembleFullRoute(t *testing.T) { + db := newSeededStore(t) + now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC) + + // Two stuck pending packets (older than 10m) and one fresh pending one. + stuckOld := seedPacket(t, db, 1, now.Add(-30*time.Minute), store.RelayStatusDeliverRecvPacket) + linkSubmission(t, db, stuckOld, "0x11") + linkSubmission(t, db, stuckOld, "0x12") + + stuckMid := seedPacket(t, db, 2, now.Add(-15*time.Minute), store.RelayStatusPending) + _ = stuckMid + + seedPacket(t, db, 3, now.Add(-1*time.Minute), store.RelayStatusPending) // fresh, not stuck + + // Terminal packets across the wire vocabulary. + seedPacket(t, db, 4, now.Add(-2*time.Hour), store.RelayStatusCompleteWithAck) + seedPacket(t, db, 5, now.Add(-2*time.Hour), store.RelayStatusCompleteWithTimeout) + seedPacket(t, db, 6, now.Add(-2*time.Hour), store.RelayStatusFailed) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true, RouterHasCode: boolPtr(true), ClientRegistered: boolPtr(true)}, + chainDest: {Reachable: false, Err: context.DeadlineExceeded}, + }} + att := fakeAttestorProber{byName: map[string]heightResult{ + "att-remote-1": {height: 500}, + "att-remote-2": {err: context.DeadlineExceeded}, + "att-remote-3": {height: 700}, + }} + + report, err := status.Assemble(context.Background(), testConfig(), db, prober, att, status.Options{ + Now: now, + StuckAfter: 10 * time.Minute, + }) + require.NoError(t, err) + + require.Len(t, report.Routes, 1) + r := report.Routes[0] + + assert.Equal(t, routeAlias, r.Alias) + assert.Equal(t, chainSource, r.ChainID) + assert.Equal(t, clientSrc, r.ClientID) + assert.Equal(t, chainDest, r.Counterparty.ChainID) + assert.Equal(t, clientDst, r.Counterparty.ClientID) + + // Connection: source reachable with wiring facts; counterparty unreachable. + assert.Equal(t, status.EndpointReachable, r.Connection.Source.State) + require.NotNil(t, r.Connection.Source.RouterHasCode) + assert.True(t, *r.Connection.Source.RouterHasCode) + require.NotNil(t, r.Connection.Source.ClientRegistered) + assert.True(t, *r.Connection.Source.ClientRegistered) + + assert.Equal(t, status.EndpointUnreachable, r.Connection.Counterparty.State) + assert.NotEmpty(t, r.Connection.Counterparty.Error) + + // Packets: six total, counted by wire state. + assert.Equal(t, 6, r.Packets.Total) + assert.Equal(t, 3, r.Packets.ByState["pending"]) + assert.Equal(t, 1, r.Packets.ByState["complete"]) + assert.Equal(t, 1, r.Packets.ByState["timed_out"]) + assert.Equal(t, 1, r.Packets.ByState["failed"]) + + // Oldest pending age = 30m. + require.NotNil(t, r.Packets.OldestPendingAgeSeconds) + assert.Equal(t, int64((30 * time.Minute).Seconds()), *r.Packets.OldestPendingAgeSeconds) + + // Stuck list: the two pending packets older than 10m, not the fresh one. + require.Len(t, r.Packets.Stuck, 2) + stuck := r.Packets.Stuck[0] + assert.Equal(t, "eth-to-base-1", stuck.PacketID) + assert.Equal(t, uint64(1), stuck.Sequence) + assert.Equal(t, "pending", stuck.State) + assert.Equal(t, int64(2), stuck.Attempts) + assert.Equal(t, int64((30 * time.Minute).Seconds()), stuck.AgeSeconds) + // The second stuck packet had no submissions. + assert.Equal(t, uint64(2), r.Packets.Stuck[1].Sequence) + assert.Equal(t, int64(0), r.Packets.Stuck[1].Attempts) + + // Ack/timeout leg (source client's set): threshold 2, one remote reachable, + // one local unprobed member -> reachable(1)+local(1) >= 2 but remotes alone + // fall short, so the CLI cannot decide: "unknown". + ack := r.Quorum.AckTimeout + assert.Equal(t, 2, ack.Threshold) + assert.Equal(t, 1, ack.ReachableCount) + assert.Equal(t, status.QuorumUnknown, ack.State) + require.Len(t, ack.Attestors, 3) + + byName := map[string]status.AttestorStatus{} + for _, a := range ack.Attestors { + byName[a.Name] = a + } + assert.Equal(t, "local", byName["att-local"].Type) + assert.False(t, byName["att-local"].Probed) + assert.False(t, byName["att-local"].Reachable) + assert.NotEmpty(t, byName["att-local"].Note) + + assert.True(t, byName["att-remote-1"].Probed) + assert.True(t, byName["att-remote-1"].Reachable) + require.NotNil(t, byName["att-remote-1"].Height) + assert.Equal(t, uint64(500), *byName["att-remote-1"].Height) + + assert.True(t, byName["att-remote-2"].Probed) + assert.False(t, byName["att-remote-2"].Reachable) + assert.NotEmpty(t, byName["att-remote-2"].Error) + + // Recv leg (mirror client's set): threshold 1, its single remote reachable -> + // remotes alone meet the threshold: "ok". + recv := r.Quorum.Recv + assert.Equal(t, 1, recv.Threshold) + assert.Equal(t, 1, recv.ReachableCount) + assert.Equal(t, status.QuorumOK, recv.State) + require.Len(t, recv.Attestors, 1) + assert.Equal(t, "att-remote-3", recv.Attestors[0].Name) + assert.True(t, recv.Attestors[0].Reachable) +} + +func TestAssembleQuorumMet(t *testing.T) { + db := newSeededStore(t) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true}, + chainDest: {Reachable: true}, + }} + att := fakeAttestorProber{byName: map[string]heightResult{ + "att-remote-1": {height: 100}, + "att-remote-2": {height: 101}, + "att-remote-3": {height: 102}, + }} + + report, err := status.Assemble(context.Background(), testConfig(), db, prober, att, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + q := report.Routes[0].Quorum + // Ack/timeout leg: both remotes reachable meet threshold 2 on their own. + assert.Equal(t, 2, q.AckTimeout.ReachableCount) + assert.Equal(t, status.QuorumOK, q.AckTimeout.State) + // Recv leg: single remote reachable meets threshold 1. + assert.Equal(t, 1, q.Recv.ReachableCount) + assert.Equal(t, status.QuorumOK, q.Recv.State) +} + +// oneClientConfig builds a config with a single source client (no mirror entry) +// whose attestor set is exactly the given one. The recv leg therefore degrades +// to unknown; the source set drives the ack/timeout leg under test. +func oneClientConfig(set config.AttestorSetConfig) config.Config { + cfg := testConfig() + cfg.Relayer.Clients = []config.ClientConfig{{ + Alias: routeAlias, + ClientID: clientSrc, + ChainID: chainSource, + CounterpartyChainID: chainDest, + CounterpartyClientID: clientDst, + Type: config.ClientTypeAttestation, + AttestorSet: &set, + }} + + return cfg +} + +// TestAssembleQuorumUnknownAllLocal verifies an all-local threshold-1 set is +// reported "unknown", not a false negative: the lone local member is unprobeable +// but could satisfy the threshold, so the CLI must not claim there is no quorum. +func TestAssembleQuorumUnknownAllLocal(t *testing.T) { + db := newSeededStore(t) + + cfg := oneClientConfig(config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{{Name: "att-local", Type: config.AttestorTypeLocal}}, + }) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{chainSource: {Reachable: true}}} + + report, err := status.Assemble(context.Background(), cfg, db, prober, fakeAttestorProber{}, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + ack := report.Routes[0].Quorum.AckTimeout + assert.Equal(t, 1, ack.Threshold) + assert.Equal(t, 0, ack.ReachableCount) + assert.Equal(t, status.QuorumUnknown, ack.State) + require.Len(t, ack.Attestors, 1) + assert.False(t, ack.Attestors[0].Probed) +} + +// TestAssembleQuorumNo verifies a set that cannot reach its threshold even if +// every local member were up is reported "no": two remotes down, no local +// members, threshold 2 -> provably without quorum. +func TestAssembleQuorumNo(t *testing.T) { + db := newSeededStore(t) + + cfg := oneClientConfig(config.AttestorSetConfig{ + Threshold: 2, + Attestors: []config.AttestorEntry{ + {Name: "att-remote-1", Type: config.AttestorTypeRemote, GRPC: "http://att1:9090"}, + {Name: "att-remote-2", Type: config.AttestorTypeRemote, GRPC: "http://att2:9090"}, + }, + }) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{chainSource: {Reachable: true}}} + att := fakeAttestorProber{byName: map[string]heightResult{ + "att-remote-1": {err: context.DeadlineExceeded}, + "att-remote-2": {err: context.DeadlineExceeded}, + }} + + report, err := status.Assemble(context.Background(), cfg, db, prober, att, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + ack := report.Routes[0].Quorum.AckTimeout + assert.Equal(t, 0, ack.ReachableCount) + assert.Equal(t, status.QuorumNo, ack.State) +} + +// TestAssembleQuorumMirrorMissing verifies the recv leg degrades gracefully to +// an empty "unknown" leg when the mirror client entry is absent, rather than +// crashing or reporting a false verdict. +func TestAssembleQuorumMirrorMissing(t *testing.T) { + db := newSeededStore(t) + + cfg := oneClientConfig(config.AttestorSetConfig{ + Threshold: 1, + Attestors: []config.AttestorEntry{{Name: "att-remote-1", Type: config.AttestorTypeRemote, GRPC: "http://att1:9090"}}, + }) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{chainSource: {Reachable: true}}} + att := fakeAttestorProber{byName: map[string]heightResult{"att-remote-1": {height: 42}}} + + report, err := status.Assemble(context.Background(), cfg, db, prober, att, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + recv := report.Routes[0].Quorum.Recv + assert.Equal(t, status.QuorumUnknown, recv.State) + assert.Empty(t, recv.Attestors) + // The ack/timeout leg still resolves from the present source set. + assert.Equal(t, status.QuorumOK, report.Routes[0].Quorum.AckTimeout.State) +} + +func TestAssembleAliasFilter(t *testing.T) { + db := newSeededStore(t) + + cfg := testConfig() + // Add a second route so the filter has something to exclude. + cfg.Relayer.Clients = append(cfg.Relayer.Clients, config.ClientConfig{ + Alias: "other", + ClientID: "other-0", + ChainID: chainDest, + CounterpartyChainID: chainSource, + CounterpartyClientID: clientSrc, + Type: config.ClientTypeAttestation, + AttestorSet: &config.AttestorSetConfig{Threshold: 1, Attestors: []config.AttestorEntry{{Name: "x", Type: config.AttestorTypeLocal}}}, + }) + cfg.Relayer.Routes = append(cfg.Relayer.Routes, config.RouteConfig{SourceClient: "other"}) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true}, + chainDest: {Reachable: true}, + }} + att := fakeAttestorProber{} + + report, err := status.Assemble(context.Background(), cfg, db, prober, att, status.Options{ + Alias: routeAlias, + }) + require.NoError(t, err) + + require.Len(t, report.Routes, 1) + assert.Equal(t, routeAlias, report.Routes[0].Alias) +} + +func TestAssembleUnconfiguredChain(t *testing.T) { + db := newSeededStore(t) + + cfg := testConfig() + // Drop the counterparty chain's RPC to force "unconfigured". + cfg.Chains[1].EVM.RPC = "" + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true}, + }} + att := fakeAttestorProber{} + + report, err := status.Assemble(context.Background(), cfg, db, prober, att, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + assert.Equal(t, status.EndpointReachable, report.Routes[0].Connection.Source.State) + assert.Equal(t, status.EndpointUnconfigured, report.Routes[0].Connection.Counterparty.State) +} + +// chainIDRPCServer stands in for an EVM RPC that answers eth_chainId with the +// given hex chain id, so the live EVMProber can be exercised without a network. +func chainIDRPCServer(t *testing.T, hexChainID string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%q}`, req.ID, hexChainID) + })) +} + +// TestEVMProberWrongNetwork is the correctness regression for the finding that +// the liveness probe fetched the chain id and discarded it: an RPC that answers +// but serves a different chain than the endpoint expects must be reported as +// wrong-network (reachable, with an error, and no on-chain facts), never as a +// healthy reachable/router-ok endpoint. +func TestEVMProberWrongNetwork(t *testing.T) { + srv := chainIDRPCServer(t, "0x2") // RPC serves chain 2 + t.Cleanup(srv.Close) + + probe := status.EVMProber{}.ProbeEndpoint(context.Background(), status.Endpoint{ + ChainID: "1", // but the endpoint expects chain 1 + RPC: srv.URL, + Router: "0x00000000000000000000000000000000000000a1", + }) + + assert.True(t, probe.Reachable, "the RPC answered, so it is reachable") + assert.True(t, probe.WrongNetwork, "a chain-id mismatch is a wrong-network endpoint") + require.Error(t, probe.Err) + assert.Nil(t, probe.RouterHasCode, "on-chain facts are skipped on the wrong network") + assert.Nil(t, probe.ClientRegistered) +} + +// TestEVMProberMatchingChainNotWrongNetwork proves a matching numeric chain id is +// not flagged wrong-network; with no router to verify it degrades to plain +// reachable. +func TestEVMProberMatchingChainNotWrongNetwork(t *testing.T) { + srv := chainIDRPCServer(t, "0x1") + t.Cleanup(srv.Close) + + probe := status.EVMProber{}.ProbeEndpoint(context.Background(), status.Endpoint{ + ChainID: "1", + RPC: srv.URL, + // No router configured, so the probe stops after the liveness/identity check. + }) + + assert.True(t, probe.Reachable) + assert.False(t, probe.WrongNetwork) + assert.NoError(t, probe.Err) +} + +// TestAssembleWrongNetworkState proves a wrong-network probe surfaces as the +// wrong-network endpoint state in the assembled report. +func TestAssembleWrongNetworkState(t *testing.T) { + db := newSeededStore(t) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true, WrongNetwork: true, Err: context.DeadlineExceeded}, + chainDest: {Reachable: true}, + }} + + report, err := status.Assemble(context.Background(), testConfig(), db, prober, fakeAttestorProber{}, status.Options{}) + require.NoError(t, err) + require.Len(t, report.Routes, 1) + + src := report.Routes[0].Connection.Source + assert.Equal(t, status.EndpointWrongNetwork, src.State) + assert.NotEmpty(t, src.Error) +} + +func TestAssembleDefaultStuckAfter(t *testing.T) { + db := newSeededStore(t) + now := time.Now().UTC() + + // A pending packet aged just over the default threshold is stuck. + seedPacket(t, db, 1, now.Add(-status.DefaultStuckAfter-time.Minute), store.RelayStatusPending) + // One just under is not. + seedPacket(t, db, 2, now.Add(-time.Minute), store.RelayStatusPending) + + prober := fakeChainProber{byChain: map[string]status.EndpointProbe{ + chainSource: {Reachable: true}, + chainDest: {Reachable: true}, + }} + + report, err := status.Assemble(context.Background(), testConfig(), db, prober, fakeAttestorProber{}, status.Options{ + Now: now, + }) + require.NoError(t, err) + + require.Len(t, report.Routes, 1) + require.Len(t, report.Routes[0].Packets.Stuck, 1) + assert.Equal(t, uint64(1), report.Routes[0].Packets.Stuck[0].Sequence) + assert.Equal(t, status.DefaultStuckAfter.String(), report.StuckAfter) +} diff --git a/link/internal/store/AGENTS.md b/link/internal/store/AGENTS.md index cd439c598..81a6609a0 100644 --- a/link/internal/store/AGENTS.md +++ b/link/internal/store/AGENTS.md @@ -7,11 +7,13 @@ To create a migration, run the script from the project's root (./link) ```bash -go run scripts/migratenew.go relay-submissions -✔︎ Created link/internal/store/migrations/sqlite/001-relay-submissions.sql -✔︎ Created link/internal/store/migrations/postgres/001-relay-submissions.sql +go run scripts/migratenew.go add-packet-index +✔︎ Created /path/to/ibc/link/internal/store/migrations/sqlite/002-add-packet-index.sql +✔︎ Created /path/to/ibc/link/internal/store/migrations/postgres/002-add-packet-index.sql ``` +The script prints absolute paths and assigns the next migration number automatically. + Edit both `migrations/sqlite/*.sql` and `migrations/postgres/*.sql`; keep behavior identical, syntax dialect-specific. ## II. Query creation, code generation diff --git a/link/internal/store/migrations/postgres/001-initial-schema.sql b/link/internal/store/migrations/postgres/001-initial-schema.sql new file mode 100644 index 000000000..1631d658a --- /dev/null +++ b/link/internal/store/migrations/postgres/001-initial-schema.sql @@ -0,0 +1,85 @@ +-- +migrate Up + +create table if not exists packets ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + status text NOT NULL, + source_chain_id text NOT NULL, + destination_chain_id text NOT NULL, + source_tx_hash text NOT NULL, + source_tx_time timestamp with time zone NOT NULL, + packet_sequence_number bigint NOT NULL, + packet_source_client_id text NOT NULL, + packet_destination_client_id text NOT NULL, + packet_timeout_timestamp timestamp with time zone NOT NULL, + recv_tx_hash text, + recv_tx_time timestamp with time zone, + recv_tx_relayer_address text, + write_ack_tx_hash text, + write_ack_tx_time timestamp with time zone, + write_ack_status text, + ack_tx_hash text, + ack_tx_time timestamp with time zone, + ack_tx_relayer_address text, + timeout_tx_hash text, + timeout_tx_time timestamp with time zone, + timeout_tx_relayer_address text, + source_tx_finalized_time timestamp with time zone, + write_ack_tx_finalized_time timestamp with time zone +); + +create unique index if not exists index_packet + on packets (source_chain_id, packet_sequence_number, packet_source_client_id); + +create index if not exists index_packet_status + on packets (status); + +create table if not exists relay_requests ( + id bigserial PRIMARY KEY, + source_chain_id text NOT NULL, + source_tx_hash text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + + constraint relay_requests_source_tx_unique + unique (source_chain_id, source_tx_hash) +); + +create table if not exists relayer_tx_submissions ( + id bigserial PRIMARY KEY, + tx_hash text NOT NULL, + chain_id text NOT NULL, + tx_type text NOT NULL, + relayer_address text NOT NULL, + submitted_at timestamp with time zone NOT NULL DEFAULT now(), + resolved_at timestamp with time zone, + gas_cost_amount numeric, + status text NOT NULL, + execution_error text, + + constraint relayer_tx_submissions_tx_unique + unique (chain_id, tx_hash) +); + +create table if not exists packet_tx_submissions ( + packet_id bigint NOT NULL references packets (id), + submission_id bigint NOT NULL references relayer_tx_submissions (id), + + primary key (packet_id, submission_id) +); + +-- Relay cursors are keyed per (source chain, source client) as +-- "/" so a later-enabled route on an already-scanned +-- chain starts from its own lookback. cursor_key holds that composite string. +create table if not exists relay_cursors ( + cursor_key text primary key, + height bigint not null, + updated_at timestamp with time zone not null default now() +); + +-- +migrate Down +drop table if exists relay_cursors; +drop table if exists packet_tx_submissions; +drop table if exists relayer_tx_submissions; +drop table if exists relay_requests; +drop table if exists packets; diff --git a/link/internal/store/migrations/postgres/001-packets.sql b/link/internal/store/migrations/postgres/001-packets.sql deleted file mode 100644 index 0854fce97..000000000 --- a/link/internal/store/migrations/postgres/001-packets.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +migrate Up - -create table if not exists packets ( - id bigserial PRIMARY KEY, - created_at timestamp with time zone NOT NULL DEFAULT now(), - updated_at timestamp with time zone NOT NULL DEFAULT now(), - status text NOT NULL, - source_chain_id text NOT NULL, - destination_chain_id text NOT NULL, - source_tx_hash text NOT NULL, - source_tx_time timestamp with time zone NOT NULL, - packet_sequence_number bigint NOT NULL, - packet_source_client_id text NOT NULL, - packet_destination_client_id text NOT NULL, - packet_timeout_timestamp timestamp with time zone NOT NULL, - recv_tx_hash text, - recv_tx_time timestamp with time zone, - recv_tx_relayer_address text, - write_ack_tx_hash text, - write_ack_tx_time timestamp with time zone, - write_ack_status text, - ack_tx_hash text, - ack_tx_time timestamp with time zone, - ack_tx_relayer_address text, - timeout_tx_hash text, - timeout_tx_time timestamp with time zone, - timeout_tx_relayer_address text -); - -create unique index if not exists index_packet - on packets (source_chain_id, packet_sequence_number, packet_source_client_id); - --- +migrate Down -drop table if exists packets; diff --git a/link/internal/store/migrations/postgres/002-relay-requests.sql b/link/internal/store/migrations/postgres/002-relay-requests.sql deleted file mode 100644 index f4b594aab..000000000 --- a/link/internal/store/migrations/postgres/002-relay-requests.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +migrate Up - -create table if not exists relay_requests ( - id bigserial PRIMARY KEY, - source_chain_id text NOT NULL, - source_tx_hash text NOT NULL, - created_at timestamp with time zone NOT NULL DEFAULT now(), - - constraint relay_requests_source_tx_unique - unique (source_chain_id, source_tx_hash) -); - --- +migrate Down -drop table if exists relay_requests; diff --git a/link/internal/store/migrations/postgres/003-relayer-tx-submissions.sql b/link/internal/store/migrations/postgres/003-relayer-tx-submissions.sql deleted file mode 100644 index 96f4853af..000000000 --- a/link/internal/store/migrations/postgres/003-relayer-tx-submissions.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +migrate Up - -create table if not exists relayer_tx_submissions ( - id bigserial PRIMARY KEY, - tx_hash text NOT NULL, - chain_id text NOT NULL, - tx_type text NOT NULL, - relayer_address text NOT NULL, - submitted_at timestamp with time zone NOT NULL DEFAULT now(), - resolved_at timestamp with time zone, - gas_cost_amount numeric, - status text NOT NULL, - execution_error text, - - constraint relayer_tx_submissions_tx_unique - unique (chain_id, tx_hash) -); - --- +migrate Down -drop table if exists relayer_tx_submissions; diff --git a/link/internal/store/migrations/postgres/004-packet-tx-submissions.sql b/link/internal/store/migrations/postgres/004-packet-tx-submissions.sql deleted file mode 100644 index 1db291aa4..000000000 --- a/link/internal/store/migrations/postgres/004-packet-tx-submissions.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +migrate Up - -create table if not exists packet_tx_submissions ( - packet_id bigint NOT NULL references packets (id), - submission_id bigint NOT NULL references relayer_tx_submissions (id), - - primary key (packet_id, submission_id) -); - --- +migrate Down -drop table if exists packet_tx_submissions; diff --git a/link/internal/store/migrations/sqlite/001-initial-schema.sql b/link/internal/store/migrations/sqlite/001-initial-schema.sql new file mode 100644 index 000000000..132f90fcc --- /dev/null +++ b/link/internal/store/migrations/sqlite/001-initial-schema.sql @@ -0,0 +1,85 @@ +-- +migrate Up + +create table if not exists packets ( + id integer primary key, + created_at timestamp not null default current_timestamp, + updated_at timestamp not null default current_timestamp, + status text not null, + source_chain_id text not null, + destination_chain_id text not null, + source_tx_hash text not null, + source_tx_time timestamp not null, + packet_sequence_number integer not null, + packet_source_client_id text not null, + packet_destination_client_id text not null, + packet_timeout_timestamp timestamp not null, + recv_tx_hash text, + recv_tx_time timestamp, + recv_tx_relayer_address text, + write_ack_tx_hash text, + write_ack_tx_time timestamp, + write_ack_status text, + ack_tx_hash text, + ack_tx_time timestamp, + ack_tx_relayer_address text, + timeout_tx_hash text, + timeout_tx_time timestamp, + timeout_tx_relayer_address text, + source_tx_finalized_time timestamp, + write_ack_tx_finalized_time timestamp +); + +create unique index if not exists index_packet + on packets (source_chain_id, packet_sequence_number, packet_source_client_id); + +create index if not exists index_packet_status + on packets (status); + +create table if not exists relay_requests ( + id integer primary key, + source_chain_id text not null, + source_tx_hash text not null, + created_at timestamp not null default current_timestamp, + + constraint relay_requests_source_tx_unique + unique (source_chain_id, source_tx_hash) +); + +create table if not exists relayer_tx_submissions ( + id integer primary key, + tx_hash text not null, + chain_id text not null, + tx_type text not null, + relayer_address text not null, + submitted_at timestamp not null default current_timestamp, + resolved_at timestamp, + gas_cost_amount text, + status text not null, + execution_error text, + + constraint relayer_tx_submissions_tx_unique + unique (chain_id, tx_hash) +); + +create table if not exists packet_tx_submissions ( + packet_id integer not null references packets (id), + submission_id integer not null references relayer_tx_submissions (id), + + primary key (packet_id, submission_id) +); + +-- Relay cursors are keyed per (source chain, source client) as +-- "/" so a later-enabled route on an already-scanned +-- chain starts from its own lookback. cursor_key holds that composite string. +create table if not exists relay_cursors ( + cursor_key text primary key, + height integer not null, + updated_at timestamp not null default current_timestamp +); + +-- +migrate Down +drop table if exists relay_cursors; +drop table if exists packet_tx_submissions; +drop table if exists relayer_tx_submissions; +drop table if exists relay_requests; +drop table if exists packets; diff --git a/link/internal/store/migrations/sqlite/001-packets.sql b/link/internal/store/migrations/sqlite/001-packets.sql deleted file mode 100644 index ec8bb236b..000000000 --- a/link/internal/store/migrations/sqlite/001-packets.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +migrate Up - -create table if not exists packets ( - id integer primary key, - created_at timestamp not null default current_timestamp, - updated_at timestamp not null default current_timestamp, - status text not null, - source_chain_id text not null, - destination_chain_id text not null, - source_tx_hash text not null, - source_tx_time timestamp not null, - packet_sequence_number integer not null, - packet_source_client_id text not null, - packet_destination_client_id text not null, - packet_timeout_timestamp timestamp not null, - recv_tx_hash text, - recv_tx_time timestamp, - recv_tx_relayer_address text, - write_ack_tx_hash text, - write_ack_tx_time timestamp, - write_ack_status text, - ack_tx_hash text, - ack_tx_time timestamp, - ack_tx_relayer_address text, - timeout_tx_hash text, - timeout_tx_time timestamp, - timeout_tx_relayer_address text -); - -create unique index if not exists index_packet - on packets (source_chain_id, packet_sequence_number, packet_source_client_id); - --- +migrate Down -drop table if exists packets; diff --git a/link/internal/store/migrations/sqlite/002-relay-requests.sql b/link/internal/store/migrations/sqlite/002-relay-requests.sql deleted file mode 100644 index 23d2b6c97..000000000 --- a/link/internal/store/migrations/sqlite/002-relay-requests.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +migrate Up - -create table if not exists relay_requests ( - id integer primary key, - source_chain_id text not null, - source_tx_hash text not null, - created_at timestamp not null default current_timestamp, - - constraint relay_requests_source_tx_unique - unique (source_chain_id, source_tx_hash) -); - --- +migrate Down -drop table if exists relay_requests; diff --git a/link/internal/store/migrations/sqlite/003-relayer-tx-submissions.sql b/link/internal/store/migrations/sqlite/003-relayer-tx-submissions.sql deleted file mode 100644 index 4ecfd45f0..000000000 --- a/link/internal/store/migrations/sqlite/003-relayer-tx-submissions.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +migrate Up - -create table if not exists relayer_tx_submissions ( - id integer primary key, - tx_hash text not null, - chain_id text not null, - tx_type text not null, - relayer_address text not null, - submitted_at timestamp not null default current_timestamp, - resolved_at timestamp, - gas_cost_amount text, - status text not null, - execution_error text, - - constraint relayer_tx_submissions_tx_unique - unique (chain_id, tx_hash) -); - --- +migrate Down -drop table if exists relayer_tx_submissions; diff --git a/link/internal/store/migrations/sqlite/004-packet-tx-submissions.sql b/link/internal/store/migrations/sqlite/004-packet-tx-submissions.sql deleted file mode 100644 index b3ea11bac..000000000 --- a/link/internal/store/migrations/sqlite/004-packet-tx-submissions.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +migrate Up - -create table if not exists packet_tx_submissions ( - packet_id integer not null references packets (id), - submission_id integer not null references relayer_tx_submissions (id), - - primary key (packet_id, submission_id) -); - --- +migrate Down -drop table if exists packet_tx_submissions; diff --git a/link/internal/store/queries/relayer.sql b/link/internal/store/queries/relayer.sql index b8bf7c696..fa1deb5e3 100644 --- a/link/internal/store/queries/relayer.sql +++ b/link/internal/store/queries/relayer.sql @@ -37,3 +37,187 @@ SELECT * FROM packets WHERE source_chain_id = sqlc.arg(chain_id) AND source_tx_hash = sqlc.arg(tx_hash) ORDER BY packet_sequence_number; + +-- name: GetPacket :one +SELECT * FROM packets +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: ListUnfinishedPackets :many +SELECT * FROM packets +WHERE status NOT IN ( + 'COMPLETE_WITH_ACK', + 'COMPLETE_WITH_TIMEOUT', + 'FAILED', + 'FAILED_INVARIANT', + 'COMPLETE_WITH_WRITE_ACK_SUCCESS', + 'COMPLETE_WITH_WRITE_ACK_ERROR' +) +ORDER BY id; + +-- name: ListPackets :many +SELECT * FROM packets +WHERE (CAST(sqlc.narg(source_chain_id) AS TEXT) IS NULL OR source_chain_id = sqlc.narg(source_chain_id)) +AND (CAST(sqlc.narg(source_client_id) AS TEXT) IS NULL OR packet_source_client_id = sqlc.narg(source_client_id)) +ORDER BY id; + +-- name: UpdatePacketStatus :exec +UPDATE packets +SET status = sqlc.arg(status), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = sqlc.arg(recv_tx_hash), + recv_tx_time = sqlc.arg(recv_tx_time), + recv_tx_relayer_address = sqlc.arg(recv_tx_relayer_address), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = sqlc.arg(ack_tx_hash), + ack_tx_time = sqlc.arg(ack_tx_time), + ack_tx_relayer_address = sqlc.arg(ack_tx_relayer_address), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = sqlc.arg(timeout_tx_hash), + timeout_tx_time = sqlc.arg(timeout_tx_time), + timeout_tx_relayer_address = sqlc.arg(timeout_tx_relayer_address), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketWriteAckTx :exec +UPDATE packets +SET write_ack_tx_hash = sqlc.arg(write_ack_tx_hash), + write_ack_tx_time = sqlc.arg(write_ack_tx_time), + write_ack_status = sqlc.arg(write_ack_status), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketWriteAckStatus :exec +UPDATE packets +SET write_ack_status = sqlc.arg(write_ack_status), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: ClearPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = NULL, + recv_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: ClearPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = NULL, + ack_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: ClearPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = NULL, + timeout_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketSourceTxFinalizedTime :exec +UPDATE packets +SET source_tx_finalized_time = sqlc.arg(source_tx_finalized_time), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: SetPacketWriteAckTxFinalizedTime :exec +UPDATE packets +SET write_ack_tx_finalized_time = sqlc.arg(write_ack_tx_finalized_time), + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = sqlc.arg(source_chain_id) +AND packet_sequence_number = sqlc.arg(packet_sequence_number) +AND packet_source_client_id = sqlc.arg(packet_source_client_id); + +-- name: CreateTxSubmission :one +INSERT INTO relayer_tx_submissions ( + tx_hash, + chain_id, + tx_type, + relayer_address, + submitted_at, + status +) VALUES ( + sqlc.arg(tx_hash), + sqlc.arg(chain_id), + sqlc.arg(tx_type), + sqlc.arg(relayer_address), + sqlc.arg(submitted_at), + sqlc.arg(status) +) +RETURNING id; + +-- name: LinkPacketTxSubmission :exec +INSERT INTO packet_tx_submissions (packet_id, submission_id) +VALUES (sqlc.arg(packet_id), sqlc.arg(submission_id)) +ON CONFLICT (packet_id, submission_id) DO NOTHING; + +-- name: GetTxSubmission :one +SELECT * FROM relayer_tx_submissions +WHERE chain_id = sqlc.arg(chain_id) +AND tx_hash = sqlc.arg(tx_hash); + +-- name: CountPacketSubmissions :many +-- Attempts per packet = number of linked tx submissions. Scoped by the same +-- optional (source chain, source client) filter as ListPackets so `ibc status` +-- can fetch attempt counts for a single route in one aggregate query rather than +-- one query per packet. Packets with no submissions are absent (attempts 0). +SELECT pts.packet_id AS packet_id, COUNT(*) AS attempts +FROM packet_tx_submissions pts +JOIN packets p ON p.id = pts.packet_id +WHERE (CAST(sqlc.narg(source_chain_id) AS TEXT) IS NULL OR p.source_chain_id = sqlc.narg(source_chain_id)) +AND (CAST(sqlc.narg(source_client_id) AS TEXT) IS NULL OR p.packet_source_client_id = sqlc.narg(source_client_id)) +GROUP BY pts.packet_id; + +-- name: ResolveTxSubmission :execrows +UPDATE relayer_tx_submissions +SET status = sqlc.arg(status), + gas_cost_amount = sqlc.narg(gas_cost_amount), + execution_error = sqlc.narg(execution_error), + resolved_at = sqlc.arg(resolved_at) +WHERE chain_id = sqlc.arg(chain_id) +AND tx_hash = sqlc.arg(tx_hash) +AND status = 'PENDING'; + +-- name: GetRelayCursor :one +SELECT height FROM relay_cursors +WHERE cursor_key = sqlc.arg(cursor_key); + +-- name: SetRelayCursor :exec +INSERT INTO relay_cursors (cursor_key, height, updated_at) +VALUES (sqlc.arg(cursor_key), sqlc.arg(height), CURRENT_TIMESTAMP) +ON CONFLICT (cursor_key) DO UPDATE +SET height = excluded.height, + updated_at = CURRENT_TIMESTAMP; diff --git a/link/internal/store/repository/postgres/models.go b/link/internal/store/repository/postgres/models.go index df39df48e..0f37a360a 100644 --- a/link/internal/store/repository/postgres/models.go +++ b/link/internal/store/repository/postgres/models.go @@ -33,6 +33,8 @@ type Packet struct { TimeoutTxHash *string TimeoutTxTime pgtype.Timestamptz TimeoutTxRelayerAddress *string + SourceTxFinalizedTime pgtype.Timestamptz + WriteAckTxFinalizedTime pgtype.Timestamptz } type PacketTxSubmission struct { @@ -40,6 +42,12 @@ type PacketTxSubmission struct { SubmissionID int64 } +type RelayCursor struct { + CursorKey string + Height int64 + UpdatedAt pgtype.Timestamptz +} + type RelayRequest struct { ID int64 SourceChainID string diff --git a/link/internal/store/repository/postgres/relayer.sql.go b/link/internal/store/repository/postgres/relayer.sql.go index 22243bf8e..939406205 100644 --- a/link/internal/store/repository/postgres/relayer.sql.go +++ b/link/internal/store/repository/postgres/relayer.sql.go @@ -11,6 +11,107 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const clearPacketAckTx = `-- name: ClearPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = NULL, + ack_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $1 +AND packet_sequence_number = $2 +AND packet_source_client_id = $3 +` + +type ClearPacketAckTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketAckTx(ctx context.Context, arg ClearPacketAckTxParams) error { + _, err := q.db.Exec(ctx, clearPacketAckTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const clearPacketRecvTx = `-- name: ClearPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = NULL, + recv_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $1 +AND packet_sequence_number = $2 +AND packet_source_client_id = $3 +` + +type ClearPacketRecvTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketRecvTx(ctx context.Context, arg ClearPacketRecvTxParams) error { + _, err := q.db.Exec(ctx, clearPacketRecvTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const clearPacketTimeoutTx = `-- name: ClearPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = NULL, + timeout_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $1 +AND packet_sequence_number = $2 +AND packet_source_client_id = $3 +` + +type ClearPacketTimeoutTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketTimeoutTx(ctx context.Context, arg ClearPacketTimeoutTxParams) error { + _, err := q.db.Exec(ctx, clearPacketTimeoutTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const countPacketSubmissions = `-- name: CountPacketSubmissions :many +SELECT pts.packet_id AS packet_id, COUNT(*) AS attempts +FROM packet_tx_submissions pts +JOIN packets p ON p.id = pts.packet_id +WHERE (CAST($1 AS TEXT) IS NULL OR p.source_chain_id = $1) +AND (CAST($2 AS TEXT) IS NULL OR p.packet_source_client_id = $2) +GROUP BY pts.packet_id +` + +type CountPacketSubmissionsRow struct { + PacketID int64 + Attempts int64 +} + +// Attempts per packet = number of linked tx submissions. Scoped by the same +// optional (source chain, source client) filter as ListPackets so `ibc status` +// can fetch attempt counts for a single route in one aggregate query rather than +// one query per packet. Packets with no submissions are absent (attempts 0). +func (q *Queries) CountPacketSubmissions(ctx context.Context, sourceChainID *string, sourceClientID *string) ([]CountPacketSubmissionsRow, error) { + rows, err := q.db.Query(ctx, countPacketSubmissions, sourceChainID, sourceClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountPacketSubmissionsRow + for rows.Next() { + var i CountPacketSubmissionsRow + if err := rows.Scan(&i.PacketID, &i.Attempts); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const createPacket = `-- name: CreatePacket :execrows INSERT INTO packets ( status, @@ -77,6 +178,107 @@ func (q *Queries) CreateRelayRequest(ctx context.Context, chainID string, txHash return err } +const createTxSubmission = `-- name: CreateTxSubmission :one +INSERT INTO relayer_tx_submissions ( + tx_hash, + chain_id, + tx_type, + relayer_address, + submitted_at, + status +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) +RETURNING id +` + +type CreateTxSubmissionParams struct { + TxHash string + ChainID string + TxType string + RelayerAddress string + SubmittedAt pgtype.Timestamptz + Status string +} + +func (q *Queries) CreateTxSubmission(ctx context.Context, arg CreateTxSubmissionParams) (int64, error) { + row := q.db.QueryRow(ctx, createTxSubmission, + arg.TxHash, + arg.ChainID, + arg.TxType, + arg.RelayerAddress, + arg.SubmittedAt, + arg.Status, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const getPacket = `-- name: GetPacket :one +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE source_chain_id = $1 +AND packet_sequence_number = $2 +AND packet_source_client_id = $3 +` + +type GetPacketParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) GetPacket(ctx context.Context, arg GetPacketParams) (Packet, error) { + row := q.db.QueryRow(ctx, getPacket, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + var i Packet + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ) + return i, err +} + +const getRelayCursor = `-- name: GetRelayCursor :one +SELECT height FROM relay_cursors +WHERE cursor_key = $1 +` + +func (q *Queries) GetRelayCursor(ctx context.Context, cursorKey string) (int64, error) { + row := q.db.QueryRow(ctx, getRelayCursor, cursorKey) + var height int64 + err := row.Scan(&height) + return height, err +} + const getRelayRequest = `-- name: GetRelayRequest :one SELECT id, source_chain_id, source_tx_hash, created_at FROM relay_requests WHERE source_chain_id = $1 @@ -95,8 +297,97 @@ func (q *Queries) GetRelayRequest(ctx context.Context, chainID string, txHash st return i, err } +const getTxSubmission = `-- name: GetTxSubmission :one +SELECT id, tx_hash, chain_id, tx_type, relayer_address, submitted_at, resolved_at, gas_cost_amount, status, execution_error FROM relayer_tx_submissions +WHERE chain_id = $1 +AND tx_hash = $2 +` + +func (q *Queries) GetTxSubmission(ctx context.Context, chainID string, txHash string) (RelayerTxSubmission, error) { + row := q.db.QueryRow(ctx, getTxSubmission, chainID, txHash) + var i RelayerTxSubmission + err := row.Scan( + &i.ID, + &i.TxHash, + &i.ChainID, + &i.TxType, + &i.RelayerAddress, + &i.SubmittedAt, + &i.ResolvedAt, + &i.GasCostAmount, + &i.Status, + &i.ExecutionError, + ) + return i, err +} + +const linkPacketTxSubmission = `-- name: LinkPacketTxSubmission :exec +INSERT INTO packet_tx_submissions (packet_id, submission_id) +VALUES ($1, $2) +ON CONFLICT (packet_id, submission_id) DO NOTHING +` + +func (q *Queries) LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error { + _, err := q.db.Exec(ctx, linkPacketTxSubmission, packetID, submissionID) + return err +} + +const listPackets = `-- name: ListPackets :many +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE (CAST($1 AS TEXT) IS NULL OR source_chain_id = $1) +AND (CAST($2 AS TEXT) IS NULL OR packet_source_client_id = $2) +ORDER BY id +` + +func (q *Queries) ListPackets(ctx context.Context, sourceChainID *string, sourceClientID *string) ([]Packet, error) { + rows, err := q.db.Query(ctx, listPackets, sourceChainID, sourceClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Packet + for rows.Next() { + var i Packet + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listPacketsBySourceTx = `-- name: ListPacketsBySourceTx :many -SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address FROM packets +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets WHERE source_chain_id = $1 AND source_tx_hash = $2 ORDER BY packet_sequence_number @@ -136,6 +427,8 @@ func (q *Queries) ListPacketsBySourceTx(ctx context.Context, chainID string, txH &i.TimeoutTxHash, &i.TimeoutTxTime, &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, ); err != nil { return nil, err } @@ -146,3 +439,343 @@ func (q *Queries) ListPacketsBySourceTx(ctx context.Context, chainID string, txH } return items, nil } + +const listUnfinishedPackets = `-- name: ListUnfinishedPackets :many +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE status NOT IN ( + 'COMPLETE_WITH_ACK', + 'COMPLETE_WITH_TIMEOUT', + 'FAILED', + 'FAILED_INVARIANT', + 'COMPLETE_WITH_WRITE_ACK_SUCCESS', + 'COMPLETE_WITH_WRITE_ACK_ERROR' +) +ORDER BY id +` + +func (q *Queries) ListUnfinishedPackets(ctx context.Context) ([]Packet, error) { + rows, err := q.db.Query(ctx, listUnfinishedPackets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Packet + for rows.Next() { + var i Packet + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveTxSubmission = `-- name: ResolveTxSubmission :execrows +UPDATE relayer_tx_submissions +SET status = $1, + gas_cost_amount = $2, + execution_error = $3, + resolved_at = $4 +WHERE chain_id = $5 +AND tx_hash = $6 +AND status = 'PENDING' +` + +type ResolveTxSubmissionParams struct { + Status string + GasCostAmount pgtype.Numeric + ExecutionError *string + ResolvedAt pgtype.Timestamptz + ChainID string + TxHash string +} + +func (q *Queries) ResolveTxSubmission(ctx context.Context, arg ResolveTxSubmissionParams) (int64, error) { + result, err := q.db.Exec(ctx, resolveTxSubmission, + arg.Status, + arg.GasCostAmount, + arg.ExecutionError, + arg.ResolvedAt, + arg.ChainID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setPacketAckTx = `-- name: SetPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = $1, + ack_tx_time = $2, + ack_tx_relayer_address = $3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $4 +AND packet_sequence_number = $5 +AND packet_source_client_id = $6 +` + +type SetPacketAckTxParams struct { + AckTxHash *string + AckTxTime pgtype.Timestamptz + AckTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketAckTx(ctx context.Context, arg SetPacketAckTxParams) error { + _, err := q.db.Exec(ctx, setPacketAckTx, + arg.AckTxHash, + arg.AckTxTime, + arg.AckTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketRecvTx = `-- name: SetPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = $1, + recv_tx_time = $2, + recv_tx_relayer_address = $3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $4 +AND packet_sequence_number = $5 +AND packet_source_client_id = $6 +` + +type SetPacketRecvTxParams struct { + RecvTxHash *string + RecvTxTime pgtype.Timestamptz + RecvTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketRecvTx(ctx context.Context, arg SetPacketRecvTxParams) error { + _, err := q.db.Exec(ctx, setPacketRecvTx, + arg.RecvTxHash, + arg.RecvTxTime, + arg.RecvTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketSourceTxFinalizedTime = `-- name: SetPacketSourceTxFinalizedTime :exec +UPDATE packets +SET source_tx_finalized_time = $1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $2 +AND packet_sequence_number = $3 +AND packet_source_client_id = $4 +` + +type SetPacketSourceTxFinalizedTimeParams struct { + SourceTxFinalizedTime pgtype.Timestamptz + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketSourceTxFinalizedTime(ctx context.Context, arg SetPacketSourceTxFinalizedTimeParams) error { + _, err := q.db.Exec(ctx, setPacketSourceTxFinalizedTime, + arg.SourceTxFinalizedTime, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketTimeoutTx = `-- name: SetPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = $1, + timeout_tx_time = $2, + timeout_tx_relayer_address = $3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $4 +AND packet_sequence_number = $5 +AND packet_source_client_id = $6 +` + +type SetPacketTimeoutTxParams struct { + TimeoutTxHash *string + TimeoutTxTime pgtype.Timestamptz + TimeoutTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketTimeoutTx(ctx context.Context, arg SetPacketTimeoutTxParams) error { + _, err := q.db.Exec(ctx, setPacketTimeoutTx, + arg.TimeoutTxHash, + arg.TimeoutTxTime, + arg.TimeoutTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckStatus = `-- name: SetPacketWriteAckStatus :exec +UPDATE packets +SET write_ack_status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $2 +AND packet_sequence_number = $3 +AND packet_source_client_id = $4 +` + +type SetPacketWriteAckStatusParams struct { + WriteAckStatus *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckStatus(ctx context.Context, arg SetPacketWriteAckStatusParams) error { + _, err := q.db.Exec(ctx, setPacketWriteAckStatus, + arg.WriteAckStatus, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckTx = `-- name: SetPacketWriteAckTx :exec +UPDATE packets +SET write_ack_tx_hash = $1, + write_ack_tx_time = $2, + write_ack_status = $3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $4 +AND packet_sequence_number = $5 +AND packet_source_client_id = $6 +` + +type SetPacketWriteAckTxParams struct { + WriteAckTxHash *string + WriteAckTxTime pgtype.Timestamptz + WriteAckStatus *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckTx(ctx context.Context, arg SetPacketWriteAckTxParams) error { + _, err := q.db.Exec(ctx, setPacketWriteAckTx, + arg.WriteAckTxHash, + arg.WriteAckTxTime, + arg.WriteAckStatus, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckTxFinalizedTime = `-- name: SetPacketWriteAckTxFinalizedTime :exec +UPDATE packets +SET write_ack_tx_finalized_time = $1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $2 +AND packet_sequence_number = $3 +AND packet_source_client_id = $4 +` + +type SetPacketWriteAckTxFinalizedTimeParams struct { + WriteAckTxFinalizedTime pgtype.Timestamptz + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckTxFinalizedTime(ctx context.Context, arg SetPacketWriteAckTxFinalizedTimeParams) error { + _, err := q.db.Exec(ctx, setPacketWriteAckTxFinalizedTime, + arg.WriteAckTxFinalizedTime, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setRelayCursor = `-- name: SetRelayCursor :exec +INSERT INTO relay_cursors (cursor_key, height, updated_at) +VALUES ($1, $2, CURRENT_TIMESTAMP) +ON CONFLICT (cursor_key) DO UPDATE +SET height = excluded.height, + updated_at = CURRENT_TIMESTAMP +` + +func (q *Queries) SetRelayCursor(ctx context.Context, cursorKey string, height int64) error { + _, err := q.db.Exec(ctx, setRelayCursor, cursorKey, height) + return err +} + +const updatePacketStatus = `-- name: UpdatePacketStatus :exec +UPDATE packets +SET status = $1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = $2 +AND packet_sequence_number = $3 +AND packet_source_client_id = $4 +` + +type UpdatePacketStatusParams struct { + Status string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) UpdatePacketStatus(ctx context.Context, arg UpdatePacketStatusParams) error { + _, err := q.db.Exec(ctx, updatePacketStatus, + arg.Status, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} diff --git a/link/internal/store/repository/sqlite/models.go b/link/internal/store/repository/sqlite/models.go index 594c55fb8..c03f8c822 100644 --- a/link/internal/store/repository/sqlite/models.go +++ b/link/internal/store/repository/sqlite/models.go @@ -33,6 +33,8 @@ type Packet struct { TimeoutTxHash *string TimeoutTxTime *time.Time TimeoutTxRelayerAddress *string + SourceTxFinalizedTime *time.Time + WriteAckTxFinalizedTime *time.Time } type PacketTxSubmission struct { @@ -40,6 +42,12 @@ type PacketTxSubmission struct { SubmissionID int64 } +type RelayCursor struct { + CursorKey string + Height int64 + UpdatedAt time.Time +} + type RelayRequest struct { ID int64 SourceChainID string diff --git a/link/internal/store/repository/sqlite/relayer.sql.go b/link/internal/store/repository/sqlite/relayer.sql.go index 8b2206485..b7e95dacd 100644 --- a/link/internal/store/repository/sqlite/relayer.sql.go +++ b/link/internal/store/repository/sqlite/relayer.sql.go @@ -10,6 +10,110 @@ import ( "time" ) +const clearPacketAckTx = `-- name: ClearPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = NULL, + ack_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?1 +AND packet_sequence_number = ?2 +AND packet_source_client_id = ?3 +` + +type ClearPacketAckTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketAckTx(ctx context.Context, arg ClearPacketAckTxParams) error { + _, err := q.db.ExecContext(ctx, clearPacketAckTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const clearPacketRecvTx = `-- name: ClearPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = NULL, + recv_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?1 +AND packet_sequence_number = ?2 +AND packet_source_client_id = ?3 +` + +type ClearPacketRecvTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketRecvTx(ctx context.Context, arg ClearPacketRecvTxParams) error { + _, err := q.db.ExecContext(ctx, clearPacketRecvTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const clearPacketTimeoutTx = `-- name: ClearPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = NULL, + timeout_tx_time = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?1 +AND packet_sequence_number = ?2 +AND packet_source_client_id = ?3 +` + +type ClearPacketTimeoutTxParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) ClearPacketTimeoutTx(ctx context.Context, arg ClearPacketTimeoutTxParams) error { + _, err := q.db.ExecContext(ctx, clearPacketTimeoutTx, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + return err +} + +const countPacketSubmissions = `-- name: CountPacketSubmissions :many +SELECT pts.packet_id AS packet_id, COUNT(*) AS attempts +FROM packet_tx_submissions pts +JOIN packets p ON p.id = pts.packet_id +WHERE (CAST(?1 AS TEXT) IS NULL OR p.source_chain_id = ?1) +AND (CAST(?2 AS TEXT) IS NULL OR p.packet_source_client_id = ?2) +GROUP BY pts.packet_id +` + +type CountPacketSubmissionsRow struct { + PacketID int64 + Attempts int64 +} + +// Attempts per packet = number of linked tx submissions. Scoped by the same +// optional (source chain, source client) filter as ListPackets so `ibc status` +// can fetch attempt counts for a single route in one aggregate query rather than +// one query per packet. Packets with no submissions are absent (attempts 0). +func (q *Queries) CountPacketSubmissions(ctx context.Context, sourceChainID *string, sourceClientID *string) ([]CountPacketSubmissionsRow, error) { + rows, err := q.db.QueryContext(ctx, countPacketSubmissions, sourceChainID, sourceClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountPacketSubmissionsRow + for rows.Next() { + var i CountPacketSubmissionsRow + if err := rows.Scan(&i.PacketID, &i.Attempts); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const createPacket = `-- name: CreatePacket :execrows INSERT INTO packets ( status, @@ -76,6 +180,107 @@ func (q *Queries) CreateRelayRequest(ctx context.Context, chainID string, txHash return err } +const createTxSubmission = `-- name: CreateTxSubmission :one +INSERT INTO relayer_tx_submissions ( + tx_hash, + chain_id, + tx_type, + relayer_address, + submitted_at, + status +) VALUES ( + ?1, + ?2, + ?3, + ?4, + ?5, + ?6 +) +RETURNING id +` + +type CreateTxSubmissionParams struct { + TxHash string + ChainID string + TxType string + RelayerAddress string + SubmittedAt time.Time + Status string +} + +func (q *Queries) CreateTxSubmission(ctx context.Context, arg CreateTxSubmissionParams) (int64, error) { + row := q.db.QueryRowContext(ctx, createTxSubmission, + arg.TxHash, + arg.ChainID, + arg.TxType, + arg.RelayerAddress, + arg.SubmittedAt, + arg.Status, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const getPacket = `-- name: GetPacket :one +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE source_chain_id = ?1 +AND packet_sequence_number = ?2 +AND packet_source_client_id = ?3 +` + +type GetPacketParams struct { + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) GetPacket(ctx context.Context, arg GetPacketParams) (Packet, error) { + row := q.db.QueryRowContext(ctx, getPacket, arg.SourceChainID, arg.PacketSequenceNumber, arg.PacketSourceClientID) + var i Packet + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ) + return i, err +} + +const getRelayCursor = `-- name: GetRelayCursor :one +SELECT height FROM relay_cursors +WHERE cursor_key = ?1 +` + +func (q *Queries) GetRelayCursor(ctx context.Context, cursorKey string) (int64, error) { + row := q.db.QueryRowContext(ctx, getRelayCursor, cursorKey) + var height int64 + err := row.Scan(&height) + return height, err +} + const getRelayRequest = `-- name: GetRelayRequest :one SELECT id, source_chain_id, source_tx_hash, created_at FROM relay_requests WHERE source_chain_id = ?1 @@ -94,8 +299,100 @@ func (q *Queries) GetRelayRequest(ctx context.Context, chainID string, txHash st return i, err } +const getTxSubmission = `-- name: GetTxSubmission :one +SELECT id, tx_hash, chain_id, tx_type, relayer_address, submitted_at, resolved_at, gas_cost_amount, status, execution_error FROM relayer_tx_submissions +WHERE chain_id = ?1 +AND tx_hash = ?2 +` + +func (q *Queries) GetTxSubmission(ctx context.Context, chainID string, txHash string) (RelayerTxSubmission, error) { + row := q.db.QueryRowContext(ctx, getTxSubmission, chainID, txHash) + var i RelayerTxSubmission + err := row.Scan( + &i.ID, + &i.TxHash, + &i.ChainID, + &i.TxType, + &i.RelayerAddress, + &i.SubmittedAt, + &i.ResolvedAt, + &i.GasCostAmount, + &i.Status, + &i.ExecutionError, + ) + return i, err +} + +const linkPacketTxSubmission = `-- name: LinkPacketTxSubmission :exec +INSERT INTO packet_tx_submissions (packet_id, submission_id) +VALUES (?1, ?2) +ON CONFLICT (packet_id, submission_id) DO NOTHING +` + +func (q *Queries) LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error { + _, err := q.db.ExecContext(ctx, linkPacketTxSubmission, packetID, submissionID) + return err +} + +const listPackets = `-- name: ListPackets :many +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE (CAST(?1 AS TEXT) IS NULL OR source_chain_id = ?1) +AND (CAST(?2 AS TEXT) IS NULL OR packet_source_client_id = ?2) +ORDER BY id +` + +func (q *Queries) ListPackets(ctx context.Context, sourceChainID *string, sourceClientID *string) ([]Packet, error) { + rows, err := q.db.QueryContext(ctx, listPackets, sourceChainID, sourceClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Packet + for rows.Next() { + var i Packet + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listPacketsBySourceTx = `-- name: ListPacketsBySourceTx :many -SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address FROM packets +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets WHERE source_chain_id = ?1 AND source_tx_hash = ?2 ORDER BY packet_sequence_number @@ -135,6 +432,71 @@ func (q *Queries) ListPacketsBySourceTx(ctx context.Context, chainID string, txH &i.TimeoutTxHash, &i.TimeoutTxTime, &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUnfinishedPackets = `-- name: ListUnfinishedPackets :many +SELECT id, created_at, updated_at, status, source_chain_id, destination_chain_id, source_tx_hash, source_tx_time, packet_sequence_number, packet_source_client_id, packet_destination_client_id, packet_timeout_timestamp, recv_tx_hash, recv_tx_time, recv_tx_relayer_address, write_ack_tx_hash, write_ack_tx_time, write_ack_status, ack_tx_hash, ack_tx_time, ack_tx_relayer_address, timeout_tx_hash, timeout_tx_time, timeout_tx_relayer_address, source_tx_finalized_time, write_ack_tx_finalized_time FROM packets +WHERE status NOT IN ( + 'COMPLETE_WITH_ACK', + 'COMPLETE_WITH_TIMEOUT', + 'FAILED', + 'FAILED_INVARIANT', + 'COMPLETE_WITH_WRITE_ACK_SUCCESS', + 'COMPLETE_WITH_WRITE_ACK_ERROR' +) +ORDER BY id +` + +func (q *Queries) ListUnfinishedPackets(ctx context.Context) ([]Packet, error) { + rows, err := q.db.QueryContext(ctx, listUnfinishedPackets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Packet + for rows.Next() { + var i Packet + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.SourceChainID, + &i.DestinationChainID, + &i.SourceTxHash, + &i.SourceTxTime, + &i.PacketSequenceNumber, + &i.PacketSourceClientID, + &i.PacketDestinationClientID, + &i.PacketTimeoutTimestamp, + &i.RecvTxHash, + &i.RecvTxTime, + &i.RecvTxRelayerAddress, + &i.WriteAckTxHash, + &i.WriteAckTxTime, + &i.WriteAckStatus, + &i.AckTxHash, + &i.AckTxTime, + &i.AckTxRelayerAddress, + &i.TimeoutTxHash, + &i.TimeoutTxTime, + &i.TimeoutTxRelayerAddress, + &i.SourceTxFinalizedTime, + &i.WriteAckTxFinalizedTime, ); err != nil { return nil, err } @@ -148,3 +510,283 @@ func (q *Queries) ListPacketsBySourceTx(ctx context.Context, chainID string, txH } return items, nil } + +const resolveTxSubmission = `-- name: ResolveTxSubmission :execrows +UPDATE relayer_tx_submissions +SET status = ?1, + gas_cost_amount = ?2, + execution_error = ?3, + resolved_at = ?4 +WHERE chain_id = ?5 +AND tx_hash = ?6 +AND status = 'PENDING' +` + +type ResolveTxSubmissionParams struct { + Status string + GasCostAmount *string + ExecutionError *string + ResolvedAt *time.Time + ChainID string + TxHash string +} + +func (q *Queries) ResolveTxSubmission(ctx context.Context, arg ResolveTxSubmissionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, resolveTxSubmission, + arg.Status, + arg.GasCostAmount, + arg.ExecutionError, + arg.ResolvedAt, + arg.ChainID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const setPacketAckTx = `-- name: SetPacketAckTx :exec +UPDATE packets +SET ack_tx_hash = ?1, + ack_tx_time = ?2, + ack_tx_relayer_address = ?3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?4 +AND packet_sequence_number = ?5 +AND packet_source_client_id = ?6 +` + +type SetPacketAckTxParams struct { + AckTxHash *string + AckTxTime *time.Time + AckTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketAckTx(ctx context.Context, arg SetPacketAckTxParams) error { + _, err := q.db.ExecContext(ctx, setPacketAckTx, + arg.AckTxHash, + arg.AckTxTime, + arg.AckTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketRecvTx = `-- name: SetPacketRecvTx :exec +UPDATE packets +SET recv_tx_hash = ?1, + recv_tx_time = ?2, + recv_tx_relayer_address = ?3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?4 +AND packet_sequence_number = ?5 +AND packet_source_client_id = ?6 +` + +type SetPacketRecvTxParams struct { + RecvTxHash *string + RecvTxTime *time.Time + RecvTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketRecvTx(ctx context.Context, arg SetPacketRecvTxParams) error { + _, err := q.db.ExecContext(ctx, setPacketRecvTx, + arg.RecvTxHash, + arg.RecvTxTime, + arg.RecvTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketSourceTxFinalizedTime = `-- name: SetPacketSourceTxFinalizedTime :exec +UPDATE packets +SET source_tx_finalized_time = ?1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?2 +AND packet_sequence_number = ?3 +AND packet_source_client_id = ?4 +` + +type SetPacketSourceTxFinalizedTimeParams struct { + SourceTxFinalizedTime *time.Time + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketSourceTxFinalizedTime(ctx context.Context, arg SetPacketSourceTxFinalizedTimeParams) error { + _, err := q.db.ExecContext(ctx, setPacketSourceTxFinalizedTime, + arg.SourceTxFinalizedTime, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketTimeoutTx = `-- name: SetPacketTimeoutTx :exec +UPDATE packets +SET timeout_tx_hash = ?1, + timeout_tx_time = ?2, + timeout_tx_relayer_address = ?3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?4 +AND packet_sequence_number = ?5 +AND packet_source_client_id = ?6 +` + +type SetPacketTimeoutTxParams struct { + TimeoutTxHash *string + TimeoutTxTime *time.Time + TimeoutTxRelayerAddress *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketTimeoutTx(ctx context.Context, arg SetPacketTimeoutTxParams) error { + _, err := q.db.ExecContext(ctx, setPacketTimeoutTx, + arg.TimeoutTxHash, + arg.TimeoutTxTime, + arg.TimeoutTxRelayerAddress, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckStatus = `-- name: SetPacketWriteAckStatus :exec +UPDATE packets +SET write_ack_status = ?1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?2 +AND packet_sequence_number = ?3 +AND packet_source_client_id = ?4 +` + +type SetPacketWriteAckStatusParams struct { + WriteAckStatus *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckStatus(ctx context.Context, arg SetPacketWriteAckStatusParams) error { + _, err := q.db.ExecContext(ctx, setPacketWriteAckStatus, + arg.WriteAckStatus, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckTx = `-- name: SetPacketWriteAckTx :exec +UPDATE packets +SET write_ack_tx_hash = ?1, + write_ack_tx_time = ?2, + write_ack_status = ?3, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?4 +AND packet_sequence_number = ?5 +AND packet_source_client_id = ?6 +` + +type SetPacketWriteAckTxParams struct { + WriteAckTxHash *string + WriteAckTxTime *time.Time + WriteAckStatus *string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckTx(ctx context.Context, arg SetPacketWriteAckTxParams) error { + _, err := q.db.ExecContext(ctx, setPacketWriteAckTx, + arg.WriteAckTxHash, + arg.WriteAckTxTime, + arg.WriteAckStatus, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setPacketWriteAckTxFinalizedTime = `-- name: SetPacketWriteAckTxFinalizedTime :exec +UPDATE packets +SET write_ack_tx_finalized_time = ?1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?2 +AND packet_sequence_number = ?3 +AND packet_source_client_id = ?4 +` + +type SetPacketWriteAckTxFinalizedTimeParams struct { + WriteAckTxFinalizedTime *time.Time + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) SetPacketWriteAckTxFinalizedTime(ctx context.Context, arg SetPacketWriteAckTxFinalizedTimeParams) error { + _, err := q.db.ExecContext(ctx, setPacketWriteAckTxFinalizedTime, + arg.WriteAckTxFinalizedTime, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} + +const setRelayCursor = `-- name: SetRelayCursor :exec +INSERT INTO relay_cursors (cursor_key, height, updated_at) +VALUES (?1, ?2, CURRENT_TIMESTAMP) +ON CONFLICT (cursor_key) DO UPDATE +SET height = excluded.height, + updated_at = CURRENT_TIMESTAMP +` + +func (q *Queries) SetRelayCursor(ctx context.Context, cursorKey string, height int64) error { + _, err := q.db.ExecContext(ctx, setRelayCursor, cursorKey, height) + return err +} + +const updatePacketStatus = `-- name: UpdatePacketStatus :exec +UPDATE packets +SET status = ?1, + updated_at = CURRENT_TIMESTAMP +WHERE source_chain_id = ?2 +AND packet_sequence_number = ?3 +AND packet_source_client_id = ?4 +` + +type UpdatePacketStatusParams struct { + Status string + SourceChainID string + PacketSequenceNumber int64 + PacketSourceClientID string +} + +func (q *Queries) UpdatePacketStatus(ctx context.Context, arg UpdatePacketStatusParams) error { + _, err := q.db.ExecContext(ctx, updatePacketStatus, + arg.Status, + arg.SourceChainID, + arg.PacketSequenceNumber, + arg.PacketSourceClientID, + ) + return err +} diff --git a/link/internal/store/status_regression_test.go b/link/internal/store/status_regression_test.go new file mode 100644 index 000000000..45a1ae664 --- /dev/null +++ b/link/internal/store/status_regression_test.go @@ -0,0 +1,249 @@ +package store + +import ( + "context" + "net/url" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/internal/tests" +) + +// TestSqliteURLAppliesPragmasNotPlainParams is the root-cause regression guard. +// +// The modernc.org/sqlite driver only applies PRAGMAs given in the connection +// URL as `_pragma=name(value)`; a plain `journal_mode=WAL` / `busy_timeout=5000` +// query parameter is silently ignored, leaving the database on the default +// rollback journal with no busy timeout: any /status read racing an engine +// write hits SQLITE_BUSY ("database is locked") immediately. +// +// This test pins that journal_mode and busy_timeout are emitted as `_pragma` +// values (and never as plain parameters). +func TestSqliteURLAppliesPragmasNotPlainParams(t *testing.T) { + raw, err := sqliteURL(filepath.Join(t.TempDir(), "ibc.db"), DefaultSqliteConnOptions()) + require.NoError(t, err) + + u, err := url.Parse(raw) + require.NoError(t, err) + q := u.Query() + + pragmas := q["_pragma"] + assert.Contains(t, pragmas, "journal_mode(WAL)", "WAL must be applied as a _pragma so modernc honors it") + assert.Contains(t, pragmas, "busy_timeout(5000)", "busy_timeout must be applied as a _pragma so modernc honors it") + assert.Contains(t, pragmas, "foreign_keys(1)") + + // These must NOT appear as plain parameters (the ignored, broken form). + assert.Empty(t, q.Get("journal_mode"), "journal_mode must not be a plain (ignored) parameter") + assert.Empty(t, q.Get("busy_timeout"), "busy_timeout must not be a plain (ignored) parameter") +} + +// TestSqliteRuntimePragmas proves the pragmas are actually in force on live +// connections drawn from the pool, not merely present in the URL string. +func TestSqliteRuntimePragmas(t *testing.T) { + db, err := NewSqlite(filepath.Join(t.TempDir(), "ibc.db")) + require.NoError(t, err) + defer db.Close() + _, err = db.MigrateUp() + require.NoError(t, err) + + ctx := context.Background() + // Draw several connections; every one must report WAL + the busy timeout. + for i := 0; i < 8; i++ { + var journalMode string + require.NoError(t, db.db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode)) + assert.Equal(t, "wal", journalMode) + + var busyTimeout int64 + require.NoError(t, db.db.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout)) + assert.EqualValues(t, 5000, busyTimeout) + } +} + +// TestListPacketsAllStatesParity is the store-level parity regression: since +// status is an opaque string to storage, the risk this guards is the DTO +// mapping of the nullable tx columns, not the status matrix itself. A +// NULL-heavy row (fresh, untouched) and a fully-populated row (every +// SetPacket*Tx column set, status advanced to terminal) must both list +// without error and round-trip on both sqlite and postgres. +func TestListPacketsAllStatesParity(t *testing.T) { + t.Run("sqlite", func(t *testing.T) { + db, err := NewSqliteInMemory() + require.NoError(t, err) + defer db.Close() + _, err = db.MigrateUp() + require.NoError(t, err) + + testListPacketsAllStates(t, db) + }) + + t.Run("postgres", func(t *testing.T) { + tests.GuardPostgresTests(t) + + ctx := context.Background() + pg := tests.NewPostgresContainer(t) + const dbName = "ibc-link" + pg.CreateDB(dbName) + + db, err := NewPostgres(ctx, pg.URL(dbName)) + require.NoError(t, err) + defer db.Close() + _, err = db.MigrateUp() + require.NoError(t, err) + + testListPacketsAllStates(t, db) + }) +} + +// testListPacketsAllStates seeds one NULL-heavy packet (fresh, PENDING, +// nullable tx columns untouched) and one fully-populated packet (every +// SetPacket*Tx column set, status advanced to a terminal state), then asserts +// the unfiltered, chain-filtered, and client-filtered list queries all +// succeed and map the nullable columns intact in both directions. +func testListPacketsAllStates(t *testing.T, s Store) { + t.Helper() + ctx := context.Background() + + const clientID = "client-0" + txTime := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + + const nullHeavySeq, populatedSeq uint64 = 1, 2 + + // NULL-heavy row: a freshly-created packet with every nullable tx column + // left unset (the mapping's NULL path). + require.NoError(t, s.CreatePacket(ctx, CreatePacket{ + Status: RelayStatusPending, + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: "0xsource-null", + SourceTxTime: txTime, + PacketSequenceNumber: nullHeavySeq, + PacketSourceClientID: clientID, + PacketDestinationClientID: "client-1", + PacketTimeoutTimestamp: txTime.Add(time.Hour), + })) + + // Fully-populated row: every nullable tx column set, status advanced to a + // terminal state (the mapping's non-NULL path). + populatedKey := PacketKey{SourceChainID: chainIDEth, PacketSequenceNumber: populatedSeq, PacketSourceClientID: clientID} + require.NoError(t, s.CreatePacket(ctx, CreatePacket{ + Status: RelayStatusPending, + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: "0xsource-full", + SourceTxTime: txTime, + PacketSequenceNumber: populatedSeq, + PacketSourceClientID: clientID, + PacketDestinationClientID: "client-1", + PacketTimeoutTimestamp: txTime.Add(time.Hour), + })) + require.NoError(t, s.SetPacketRecvTx(ctx, populatedKey, "0xrecv", txTime, "relayer")) + require.NoError(t, s.SetPacketWriteAckTx(ctx, populatedKey, "0xwriteack", txTime, WriteAckStatusSuccess)) + require.NoError(t, s.SetPacketAckTx(ctx, populatedKey, "0xack", txTime, "relayer")) + require.NoError(t, s.SetPacketTimeoutTx(ctx, populatedKey, "0xtimeout", txTime, "relayer")) + require.NoError(t, s.SetPacketSourceTxFinalizedTime(ctx, populatedKey, txTime)) + require.NoError(t, s.SetPacketWriteAckTxFinalizedTime(ctx, populatedKey, txTime)) + require.NoError(t, s.UpdatePacketStatus(ctx, populatedKey, RelayStatusCompleteWithAck)) + + // Unfiltered list-all: never errors, returns both seeded packets. + all, err := s.ListPackets(ctx, ListPacketsFilter{}) + require.NoError(t, err) + require.Len(t, all, 2) + + var nullHeavy, populated *Packet + for i := range all { + switch all[i].PacketSequenceNumber { + case nullHeavySeq: + nullHeavy = &all[i] + case populatedSeq: + populated = &all[i] + } + } + require.NotNil(t, nullHeavy, "NULL-heavy packet missing from unfiltered list") + require.NotNil(t, populated, "populated packet missing from unfiltered list") + + // NULL-heavy row: every nullable tx column maps to nil. + assert.Nil(t, nullHeavy.RecvTxHash) + assert.Nil(t, nullHeavy.RecvTxTime) + assert.Nil(t, nullHeavy.RecvTxRelayerAddress) + assert.Nil(t, nullHeavy.WriteAckTxHash) + assert.Nil(t, nullHeavy.WriteAckStatus) + assert.Nil(t, nullHeavy.AckTxHash) + assert.Nil(t, nullHeavy.TimeoutTxHash) + assert.Nil(t, nullHeavy.SourceTxFinalizedTime) + assert.Nil(t, nullHeavy.WriteAckTxFinalizedTime) + + // Populated row: every nullable tx column round-trips intact. + require.NotNil(t, populated.RecvTxHash) + assert.Equal(t, "0xrecv", *populated.RecvTxHash) + require.NotNil(t, populated.WriteAckTxHash) + assert.Equal(t, "0xwriteack", *populated.WriteAckTxHash) + require.NotNil(t, populated.WriteAckStatus) + assert.Equal(t, WriteAckStatusSuccess, *populated.WriteAckStatus) + require.NotNil(t, populated.AckTxHash) + assert.Equal(t, "0xack", *populated.AckTxHash) + require.NotNil(t, populated.TimeoutTxHash) + assert.Equal(t, "0xtimeout", *populated.TimeoutTxHash) + require.NotNil(t, populated.SourceTxFinalizedTime) + assert.Equal(t, txTime, *populated.SourceTxFinalizedTime) + require.NotNil(t, populated.WriteAckTxFinalizedTime) + assert.Equal(t, txTime, *populated.WriteAckTxFinalizedTime) + assert.Equal(t, RelayStatusCompleteWithAck, populated.Status) + + // Chain-filtered and client-filtered lists: also never error, same coverage. + chainID := chainIDEth + byChain, err := s.ListPackets(ctx, ListPacketsFilter{SourceChainID: &chainID}) + require.NoError(t, err) + assert.Len(t, byChain, 2) + + cid := clientID + byClient, err := s.ListPackets(ctx, ListPacketsFilter{SourceClientID: &cid}) + require.NoError(t, err) + assert.Len(t, byClient, 2) +} + +// TestListPacketsDuringHeldWriteLock is the deterministic behavioral contract +// that /status reads depend on: a ListPackets read from one connection must +// succeed while another connection holds an open write transaction. WAL mode +// lets a reader proceed against a live writer, and busy_timeout absorbs the +// brief handoff at commit, so the read never sees "database is locked". +func TestListPacketsDuringHeldWriteLock(t *testing.T) { + db, err := NewSqlite(filepath.Join(t.TempDir(), "ibc.db")) + require.NoError(t, err) + defer db.Close() + _, err = db.MigrateUp() + require.NoError(t, err) + + ctx := context.Background() + const clientID = "client-0" + key := PacketKey{SourceChainID: chainIDEth, PacketSequenceNumber: 1, PacketSourceClientID: clientID} + require.NoError(t, db.CreatePacket(ctx, CreatePacket{ + Status: RelayStatusPending, + SourceChainID: chainIDEth, + DestinationChainID: chainIDBase, + SourceTxHash: "0xsource", + SourceTxTime: time.Now().UTC(), + PacketSequenceNumber: 1, + PacketSourceClientID: clientID, + PacketDestinationClientID: "client-1", + PacketTimeoutTimestamp: time.Now().Add(time.Hour).UTC(), + })) + + // Hold a write transaction open on its own connection, mirroring the + // engine's UpdatePacketStatus write path, without committing yet. + tx, err := db.db.BeginTx(ctx, nil) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, + `UPDATE packets SET status = ? WHERE source_chain_id = ? AND packet_sequence_number = ? AND packet_source_client_id = ?`, + string(RelayStatusDeliverRecvPacket), key.SourceChainID, key.PacketSequenceNumber, key.PacketSourceClientID) + require.NoError(t, err) + + // A read from another connection must succeed while the write lock is held. + _, err = db.ListPackets(ctx, ListPacketsFilter{}) + assert.NoError(t, err, "read must not fail while a write transaction is open (WAL + busy_timeout)") + + require.NoError(t, tx.Commit()) +} diff --git a/link/internal/store/store.go b/link/internal/store/store.go index e10b9a7b5..cd984350f 100644 --- a/link/internal/store/store.go +++ b/link/internal/store/store.go @@ -3,7 +3,7 @@ package store import ( "context" "database/sql" - "log/slog" + "math/big" "time" "github.com/pkg/errors" @@ -21,6 +21,13 @@ type Store interface { // ExecTx runs fn in a transaction; fn's Repository is bound to it and rolled back on error. ExecTx(ctx context.Context, fn func(Repository) error) error + // CountPacketSubmissions returns attempts per packet (number of linked relay + // tx submissions), keyed by packet id, for packets matching the (optional) + // filter. It is a single aggregate query: packets with no submissions are + // simply absent from the map (attempts 0). Used by the `ibc status` operator + // surface to report retries without an N+1 per-packet query. + CountPacketSubmissions(ctx context.Context, filter ListPacketsFilter) (map[int64]int64, error) + Ping(ctx context.Context) error Close() error } @@ -36,6 +43,65 @@ type Repository interface { CreatePacket(ctx context.Context, input CreatePacket) error ListPacketsBySourceTx(ctx context.Context, chainID string, txHash string) ([]Packet, error) + + // GetPacket fetches a single packet by its natural key; ErrNotFound if absent. + GetPacket(ctx context.Context, key PacketKey) (*Packet, error) + + // ListUnfinishedPackets returns packets that have not reached a terminal status. + ListUnfinishedPackets(ctx context.Context) ([]Packet, error) + + // ListPackets returns packets matching the (optional) filter. + ListPackets(ctx context.Context, filter ListPacketsFilter) ([]Packet, error) + + // UpdatePacketStatus updates a packet's relay status. + UpdatePacketStatus(ctx context.Context, key PacketKey, status RelayStatus) error + + SetPacketRecvTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error + SetPacketAckTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error + SetPacketTimeoutTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error + SetPacketWriteAckTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + status WriteAckStatus, + ) error + + // SetPacketWriteAckStatus records the write-ack classification only, leaving + // the write-ack tx columns untouched. + SetPacketWriteAckStatus(ctx context.Context, key PacketKey, status WriteAckStatus) error + + ClearPacketRecvTx(ctx context.Context, key PacketKey) error + ClearPacketAckTx(ctx context.Context, key PacketKey) error + ClearPacketTimeoutTx(ctx context.Context, key PacketKey) error + + SetPacketSourceTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error + SetPacketWriteAckTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error + + // CreateTxSubmission records a broadcast relay tx and returns its id. + CreateTxSubmission(ctx context.Context, input CreateTxSubmission) (int64, error) + + // LinkPacketTxSubmission links a packet to a tx submission; duplicates are a noop. + LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error + + // GetTxSubmission returns the tx submission for (chainID, txHash); ErrNotFound + // if absent. The pair is unique. + GetTxSubmission(ctx context.Context, chainID string, txHash string) (*TxSubmission, error) + + // ResolveTxSubmission records the terminal result of the broadcast tx keyed by + // (chainID, txHash). It only flips rows still PENDING, so re-resolution is a + // no-op and cannot clobber an already-resolved row. It returns the number of + // rows actually transitioned (0 when the row was already resolved), so callers + // can count a tx exactly once. + ResolveTxSubmission(ctx context.Context, input ResolveTxSubmission) (int64, error) + + // GetRelayCursor returns the scan cursor height for a cursor key + // ("/"); ErrNotFound if absent. + GetRelayCursor(ctx context.Context, cursorKey string) (uint64, error) + + // SetRelayCursor upserts the scan cursor height for a cursor key + // ("/"). + SetRelayCursor(ctx context.Context, cursorKey string, height uint64) error } // Migrator abstracts schema migrations @@ -62,27 +128,6 @@ func NewStore(ctx context.Context, cfg config.Config) (Store, error) { } } -// ValidateConfigLive assumes the config.Config is valid, -// and checks if the database is reachable. -func ValidateConfigLive(cfg config.Config) error { - // noop, don't create an empty sqlite db - if cfg.DB.Type == config.DBTypeSQLite { - return nil - } - - // contains Ping() - store, err := NewStore(context.Background(), cfg) - if err != nil { - return err - } - - if errClose := store.Close(); errClose != nil { - slog.Error("Failed to close database", "err", errClose) - } - - return nil -} - // RelayRequest a request to relay a transaction's packets. type RelayRequest struct { ID int64 @@ -115,6 +160,13 @@ const ( RelayStatusCompleteWithWriteAckError RelayStatus = "COMPLETE_WITH_WRITE_ACK_ERROR" RelayStatusCompleteWithTimeout RelayStatus = "COMPLETE_WITH_TIMEOUT" RelayStatusFailed RelayStatus = "FAILED" + // RelayStatusFailedInvariant is a terminal failure recorded when a packet + // reaches a state that violates a protocol invariant and cannot be truthfully + // completed (e.g. the source commitment was cleared but no ack or timeout + // event for the packet can be found on chain). It is distinct from FAILED so + // the derived status can surface a specific operator-visible reason instead of + // fabricating a completion. + RelayStatusFailedInvariant RelayStatus = "FAILED_INVARIANT" ) // WriteAckStatus the execution result carried by a write ack. @@ -160,6 +212,126 @@ type Packet struct { TimeoutTxHash *string TimeoutTxTime *time.Time TimeoutTxRelayerAddress *string + + SourceTxFinalizedTime *time.Time + WriteAckTxFinalizedTime *time.Time +} + +// PacketKey the natural key uniquely identifying a packet. +type PacketKey struct { + SourceChainID string + PacketSequenceNumber uint64 + PacketSourceClientID string +} + +func (k PacketKey) Validate() error { + switch { + case k.SourceChainID == "": + return errors.New("source chain id is required") + case k.PacketSequenceNumber == 0: + return errors.New("packet sequence number is required") + case k.PacketSourceClientID == "": + return errors.New("packet source client id is required") + } + + return nil +} + +// ListPacketsFilter optional filters for listing packets; zero values are ignored. +type ListPacketsFilter struct { + SourceChainID *string + SourceClientID *string +} + +func (f ListPacketsFilter) Validate() error { + switch { + case f.SourceChainID != nil && *f.SourceChainID == "": + return errors.New("source chain id filter must not be empty") + case f.SourceClientID != nil && *f.SourceClientID == "": + return errors.New("source client id filter must not be empty") + } + + return nil +} + +// TxType the type of relay transaction submitted to a chain. +type TxType string + +// Tx types +const ( + TxTypeRecvPacket TxType = "RECV_PACKET" + TxTypeAckPacket TxType = "ACK_PACKET" + TxTypeTimeoutPacket TxType = "TIMEOUT_PACKET" +) + +func (t TxType) valid() bool { + switch t { + case TxTypeRecvPacket, TxTypeAckPacket, TxTypeTimeoutPacket: + return true + default: + return false + } +} + +// TxSubmissionStatus the resolution state of a submitted relay transaction. +type TxSubmissionStatus string + +// Tx submission statuses +const ( + TxSubmissionStatusPending TxSubmissionStatus = "PENDING" + TxSubmissionStatusSuccess TxSubmissionStatus = "SUCCESS" + TxSubmissionStatusFailed TxSubmissionStatus = "FAILED" + TxSubmissionStatusExpired TxSubmissionStatus = "EXPIRED" +) + +// terminal reports whether the status is a resolved (non-PENDING) outcome. +func (s TxSubmissionStatus) terminal() bool { + switch s { + case TxSubmissionStatusSuccess, TxSubmissionStatusFailed, TxSubmissionStatusExpired: + return true + default: + return false + } +} + +// TxSubmission a broadcast relay transaction tracked until it resolves. +type TxSubmission struct { + ID int64 + TxHash string + ChainID string + TxType TxType + RelayerAddress string + SubmittedAt time.Time + ResolvedAt *time.Time + GasCostAmount *string + Status TxSubmissionStatus + ExecutionError *string +} + +// CreateTxSubmission the fields callers provide when recording a broadcast tx. +type CreateTxSubmission struct { + TxHash string + ChainID string + TxType TxType + RelayerAddress string + SubmittedAt time.Time +} + +func (t CreateTxSubmission) Validate() error { + switch { + case t.TxHash == "": + return errors.New("tx hash is required") + case t.ChainID == "": + return errors.New("chain id is required") + case !t.TxType.valid(): + return errors.New("valid tx type is required") + case t.RelayerAddress == "": + return errors.New("relayer address is required") + case t.SubmittedAt.IsZero(): + return errors.New("submitted at is required") + } + + return nil } // CreatePacket the fields callers provide when recording a packet; the @@ -201,6 +373,105 @@ func (t CreatePacket) Validate() error { return nil } +// listPacketsArgs converts an optional filter into the nullable query args +// shared by both dialects; a nil pointer is bound as SQL NULL. +func listPacketsArgs(filter ListPacketsFilter) (sourceChainID, sourceClientID *string) { + return filter.SourceChainID, filter.SourceClientID +} + +func validateSetTx(key PacketKey, txHash string, txTime time.Time, relayerAddress string) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + switch { + case txHash == "": + return errors.New("tx hash is required") + case txTime.IsZero(): + return errors.New("tx time is required") + case relayerAddress == "": + return errors.New("relayer address is required") + } + + return nil +} + +func validateSetWriteAckTx(key PacketKey, txHash string, txTime time.Time, status WriteAckStatus) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + switch { + case txHash == "": + return errors.New("tx hash is required") + case txTime.IsZero(): + return errors.New("tx time is required") + case status == "": + return errors.New("write ack status is required") + } + + return nil +} + +func validateSetWriteAckStatus(key PacketKey, status WriteAckStatus) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + if status == "" { + return errors.New("write ack status is required") + } + + return nil +} + +// ResolveTxSubmission the terminal result callers record for a broadcast tx +// keyed by (ChainID, TxHash). +type ResolveTxSubmission struct { + ChainID string + TxHash string + Status TxSubmissionStatus + GasCost *string + ExecutionError *string + ResolvedAt time.Time +} + +func (r ResolveTxSubmission) Validate() error { + switch { + case r.ChainID == "": + return errors.New("chain id is required") + case r.TxHash == "": + return errors.New("tx hash is required") + case !r.Status.terminal(): + return errors.New("terminal status is required") + case r.ResolvedAt.IsZero(): + return errors.New("resolved at is required") + } + + // Validate gas cost once at the seam so sqlite and postgres reject the same + // malformed values (postgres rejects at numeric conversion; sqlite would + // otherwise store the garbage string). + if r.GasCost != nil { + if n, ok := new(big.Int).SetString(*r.GasCost, 10); !ok || n.Sign() < 0 { + return errors.New("gas cost must be a non-negative integer") + } + } + + return nil +} + +func validateSetFinalized(key PacketKey, t time.Time) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + if t.IsZero() { + return errors.New("finalized time is required") + } + + return nil +} + // cast db-specific errors to repository errors func errNormalize(err error) error { switch { diff --git a/link/internal/store/store.mock.go b/link/internal/store/store.mock.go index 184f40ff5..491da3c35 100644 --- a/link/internal/store/store.mock.go +++ b/link/internal/store/store.mock.go @@ -7,6 +7,7 @@ package store import ( "context" mock "github.com/stretchr/testify/mock" + "time" ) // NewMockRepository creates a new instance of MockRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. @@ -36,6 +37,177 @@ func (_m *MockRepository) EXPECT() *MockRepository_Expecter { return &MockRepository_Expecter{mock: &_m.Mock} } +// ClearPacketAckTx provides a mock function for the type MockRepository +func (_mock *MockRepository) ClearPacketAckTx(ctx context.Context, key PacketKey) error { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for ClearPacketAckTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey) error); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_ClearPacketAckTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearPacketAckTx' +type MockRepository_ClearPacketAckTx_Call struct { + *mock.Call +} + +// ClearPacketAckTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +func (_e *MockRepository_Expecter) ClearPacketAckTx(ctx any, key any) *MockRepository_ClearPacketAckTx_Call { + return &MockRepository_ClearPacketAckTx_Call{Call: _e.mock.On("ClearPacketAckTx", ctx, key)} +} + +func (_c *MockRepository_ClearPacketAckTx_Call) Run(run func(ctx context.Context, key PacketKey)) *MockRepository_ClearPacketAckTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_ClearPacketAckTx_Call) Return(err error) *MockRepository_ClearPacketAckTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_ClearPacketAckTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey) error) *MockRepository_ClearPacketAckTx_Call { + _c.Call.Return(run) + return _c +} + +// ClearPacketRecvTx provides a mock function for the type MockRepository +func (_mock *MockRepository) ClearPacketRecvTx(ctx context.Context, key PacketKey) error { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for ClearPacketRecvTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey) error); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_ClearPacketRecvTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearPacketRecvTx' +type MockRepository_ClearPacketRecvTx_Call struct { + *mock.Call +} + +// ClearPacketRecvTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +func (_e *MockRepository_Expecter) ClearPacketRecvTx(ctx any, key any) *MockRepository_ClearPacketRecvTx_Call { + return &MockRepository_ClearPacketRecvTx_Call{Call: _e.mock.On("ClearPacketRecvTx", ctx, key)} +} + +func (_c *MockRepository_ClearPacketRecvTx_Call) Run(run func(ctx context.Context, key PacketKey)) *MockRepository_ClearPacketRecvTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_ClearPacketRecvTx_Call) Return(err error) *MockRepository_ClearPacketRecvTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_ClearPacketRecvTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey) error) *MockRepository_ClearPacketRecvTx_Call { + _c.Call.Return(run) + return _c +} + +// ClearPacketTimeoutTx provides a mock function for the type MockRepository +func (_mock *MockRepository) ClearPacketTimeoutTx(ctx context.Context, key PacketKey) error { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for ClearPacketTimeoutTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey) error); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_ClearPacketTimeoutTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearPacketTimeoutTx' +type MockRepository_ClearPacketTimeoutTx_Call struct { + *mock.Call +} + +// ClearPacketTimeoutTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +func (_e *MockRepository_Expecter) ClearPacketTimeoutTx(ctx any, key any) *MockRepository_ClearPacketTimeoutTx_Call { + return &MockRepository_ClearPacketTimeoutTx_Call{Call: _e.mock.On("ClearPacketTimeoutTx", ctx, key)} +} + +func (_c *MockRepository_ClearPacketTimeoutTx_Call) Run(run func(ctx context.Context, key PacketKey)) *MockRepository_ClearPacketTimeoutTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_ClearPacketTimeoutTx_Call) Return(err error) *MockRepository_ClearPacketTimeoutTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_ClearPacketTimeoutTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey) error) *MockRepository_ClearPacketTimeoutTx_Call { + _c.Call.Return(run) + return _c +} + // CreatePacket provides a mock function for the type MockRepository func (_mock *MockRepository) CreatePacket(ctx context.Context, input CreatePacket) error { ret := _mock.Called(ctx, input) @@ -156,122 +328,179 @@ func (_c *MockRepository_CreateRelayRequest_Call) RunAndReturn(run func(ctx cont return _c } -// GetRelayRequest provides a mock function for the type MockRepository -func (_mock *MockRepository) GetRelayRequest(ctx context.Context, chainID string, txHash string) (*RelayRequest, error) { - ret := _mock.Called(ctx, chainID, txHash) +// CreateTxSubmission provides a mock function for the type MockRepository +func (_mock *MockRepository) CreateTxSubmission(ctx context.Context, input CreateTxSubmission) (int64, error) { + ret := _mock.Called(ctx, input) if len(ret) == 0 { - panic("no return value specified for GetRelayRequest") + panic("no return value specified for CreateTxSubmission") } - var r0 *RelayRequest + var r0 int64 var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*RelayRequest, error)); ok { - return returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(0).(func(context.Context, CreateTxSubmission) (int64, error)); ok { + return returnFunc(ctx, input) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *RelayRequest); ok { - r0 = returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(0).(func(context.Context, CreateTxSubmission) int64); ok { + r0 = returnFunc(ctx, input) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*RelayRequest) - } + r0 = ret.Get(0).(int64) } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(1).(func(context.Context, CreateTxSubmission) error); ok { + r1 = returnFunc(ctx, input) } else { r1 = ret.Error(1) } return r0, r1 } -// MockRepository_GetRelayRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayRequest' -type MockRepository_GetRelayRequest_Call struct { +// MockRepository_CreateTxSubmission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTxSubmission' +type MockRepository_CreateTxSubmission_Call struct { *mock.Call } -// GetRelayRequest is a helper method to define mock.On call +// CreateTxSubmission is a helper method to define mock.On call // - ctx context.Context -// - chainID string -// - txHash string -func (_e *MockRepository_Expecter) GetRelayRequest(ctx any, chainID any, txHash any) *MockRepository_GetRelayRequest_Call { - return &MockRepository_GetRelayRequest_Call{Call: _e.mock.On("GetRelayRequest", ctx, chainID, txHash)} +// - input CreateTxSubmission +func (_e *MockRepository_Expecter) CreateTxSubmission(ctx any, input any) *MockRepository_CreateTxSubmission_Call { + return &MockRepository_CreateTxSubmission_Call{Call: _e.mock.On("CreateTxSubmission", ctx, input)} } -func (_c *MockRepository_GetRelayRequest_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockRepository_GetRelayRequest_Call { +func (_c *MockRepository_CreateTxSubmission_Call) Run(run func(ctx context.Context, input CreateTxSubmission)) *MockRepository_CreateTxSubmission_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 CreateTxSubmission if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) + arg1 = args[1].(CreateTxSubmission) } run( arg0, arg1, - arg2, ) }) return _c } -func (_c *MockRepository_GetRelayRequest_Call) Return(relayRequest *RelayRequest, err error) *MockRepository_GetRelayRequest_Call { - _c.Call.Return(relayRequest, err) +func (_c *MockRepository_CreateTxSubmission_Call) Return(n int64, err error) *MockRepository_CreateTxSubmission_Call { + _c.Call.Return(n, err) return _c } -func (_c *MockRepository_GetRelayRequest_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) (*RelayRequest, error)) *MockRepository_GetRelayRequest_Call { +func (_c *MockRepository_CreateTxSubmission_Call) RunAndReturn(run func(ctx context.Context, input CreateTxSubmission) (int64, error)) *MockRepository_CreateTxSubmission_Call { _c.Call.Return(run) return _c } -// ListPacketsBySourceTx provides a mock function for the type MockRepository -func (_mock *MockRepository) ListPacketsBySourceTx(ctx context.Context, chainID string, txHash string) ([]Packet, error) { - ret := _mock.Called(ctx, chainID, txHash) +// GetPacket provides a mock function for the type MockRepository +func (_mock *MockRepository) GetPacket(ctx context.Context, key PacketKey) (*Packet, error) { + ret := _mock.Called(ctx, key) if len(ret) == 0 { - panic("no return value specified for ListPacketsBySourceTx") + panic("no return value specified for GetPacket") } - var r0 []Packet + var r0 *Packet var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]Packet, error)); ok { - return returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey) (*Packet, error)); ok { + return returnFunc(ctx, key) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []Packet); ok { - r0 = returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey) *Packet); ok { + r0 = returnFunc(ctx, key) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]Packet) + r0 = ret.Get(0).(*Packet) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, chainID, txHash) + if returnFunc, ok := ret.Get(1).(func(context.Context, PacketKey) error); ok { + r1 = returnFunc(ctx, key) } else { r1 = ret.Error(1) } return r0, r1 } -// MockRepository_ListPacketsBySourceTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPacketsBySourceTx' -type MockRepository_ListPacketsBySourceTx_Call struct { +// MockRepository_GetPacket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPacket' +type MockRepository_GetPacket_Call struct { *mock.Call } -// ListPacketsBySourceTx is a helper method to define mock.On call +// GetPacket is a helper method to define mock.On call // - ctx context.Context -// - chainID string -// - txHash string -func (_e *MockRepository_Expecter) ListPacketsBySourceTx(ctx any, chainID any, txHash any) *MockRepository_ListPacketsBySourceTx_Call { - return &MockRepository_ListPacketsBySourceTx_Call{Call: _e.mock.On("ListPacketsBySourceTx", ctx, chainID, txHash)} +// - key PacketKey +func (_e *MockRepository_Expecter) GetPacket(ctx any, key any) *MockRepository_GetPacket_Call { + return &MockRepository_GetPacket_Call{Call: _e.mock.On("GetPacket", ctx, key)} } -func (_c *MockRepository_ListPacketsBySourceTx_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockRepository_ListPacketsBySourceTx_Call { +func (_c *MockRepository_GetPacket_Call) Run(run func(ctx context.Context, key PacketKey)) *MockRepository_GetPacket_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_GetPacket_Call) Return(packet *Packet, err error) *MockRepository_GetPacket_Call { + _c.Call.Return(packet, err) + return _c +} + +func (_c *MockRepository_GetPacket_Call) RunAndReturn(run func(ctx context.Context, key PacketKey) (*Packet, error)) *MockRepository_GetPacket_Call { + _c.Call.Return(run) + return _c +} + +// GetRelayCursor provides a mock function for the type MockRepository +func (_mock *MockRepository) GetRelayCursor(ctx context.Context, cursorKey string) (uint64, error) { + ret := _mock.Called(ctx, cursorKey) + + if len(ret) == 0 { + panic("no return value specified for GetRelayCursor") + } + + var r0 uint64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (uint64, error)); ok { + return returnFunc(ctx, cursorKey) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) uint64); ok { + r0 = returnFunc(ctx, cursorKey) + } else { + r0 = ret.Get(0).(uint64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, cursorKey) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_GetRelayCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayCursor' +type MockRepository_GetRelayCursor_Call struct { + *mock.Call +} + +// GetRelayCursor is a helper method to define mock.On call +// - ctx context.Context +// - cursorKey string +func (_e *MockRepository_Expecter) GetRelayCursor(ctx any, cursorKey any) *MockRepository_GetRelayCursor_Call { + return &MockRepository_GetRelayCursor_Call{Call: _e.mock.On("GetRelayCursor", ctx, cursorKey)} +} + +func (_c *MockRepository_GetRelayCursor_Call) Run(run func(ctx context.Context, cursorKey string)) *MockRepository_GetRelayCursor_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -281,25 +510,1116 @@ func (_c *MockRepository_ListPacketsBySourceTx_Call) Run(run func(ctx context.Co if args[1] != nil { arg1 = args[1].(string) } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } run( arg0, arg1, - arg2, ) }) return _c } -func (_c *MockRepository_ListPacketsBySourceTx_Call) Return(packets []Packet, err error) *MockRepository_ListPacketsBySourceTx_Call { - _c.Call.Return(packets, err) +func (_c *MockRepository_GetRelayCursor_Call) Return(v uint64, err error) *MockRepository_GetRelayCursor_Call { + _c.Call.Return(v, err) return _c } -func (_c *MockRepository_ListPacketsBySourceTx_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) ([]Packet, error)) *MockRepository_ListPacketsBySourceTx_Call { +func (_c *MockRepository_GetRelayCursor_Call) RunAndReturn(run func(ctx context.Context, cursorKey string) (uint64, error)) *MockRepository_GetRelayCursor_Call { + _c.Call.Return(run) + return _c +} + +// GetRelayRequest provides a mock function for the type MockRepository +func (_mock *MockRepository) GetRelayRequest(ctx context.Context, chainID string, txHash string) (*RelayRequest, error) { + ret := _mock.Called(ctx, chainID, txHash) + + if len(ret) == 0 { + panic("no return value specified for GetRelayRequest") + } + + var r0 *RelayRequest + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*RelayRequest, error)); ok { + return returnFunc(ctx, chainID, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *RelayRequest); ok { + r0 = returnFunc(ctx, chainID, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RelayRequest) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, chainID, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_GetRelayRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelayRequest' +type MockRepository_GetRelayRequest_Call struct { + *mock.Call +} + +// GetRelayRequest is a helper method to define mock.On call +// - ctx context.Context +// - chainID string +// - txHash string +func (_e *MockRepository_Expecter) GetRelayRequest(ctx any, chainID any, txHash any) *MockRepository_GetRelayRequest_Call { + return &MockRepository_GetRelayRequest_Call{Call: _e.mock.On("GetRelayRequest", ctx, chainID, txHash)} +} + +func (_c *MockRepository_GetRelayRequest_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockRepository_GetRelayRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_GetRelayRequest_Call) Return(relayRequest *RelayRequest, err error) *MockRepository_GetRelayRequest_Call { + _c.Call.Return(relayRequest, err) + return _c +} + +func (_c *MockRepository_GetRelayRequest_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) (*RelayRequest, error)) *MockRepository_GetRelayRequest_Call { + _c.Call.Return(run) + return _c +} + +// GetTxSubmission provides a mock function for the type MockRepository +func (_mock *MockRepository) GetTxSubmission(ctx context.Context, chainID string, txHash string) (*TxSubmission, error) { + ret := _mock.Called(ctx, chainID, txHash) + + if len(ret) == 0 { + panic("no return value specified for GetTxSubmission") + } + + var r0 *TxSubmission + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*TxSubmission, error)); ok { + return returnFunc(ctx, chainID, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *TxSubmission); ok { + r0 = returnFunc(ctx, chainID, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*TxSubmission) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, chainID, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_GetTxSubmission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTxSubmission' +type MockRepository_GetTxSubmission_Call struct { + *mock.Call +} + +// GetTxSubmission is a helper method to define mock.On call +// - ctx context.Context +// - chainID string +// - txHash string +func (_e *MockRepository_Expecter) GetTxSubmission(ctx any, chainID any, txHash any) *MockRepository_GetTxSubmission_Call { + return &MockRepository_GetTxSubmission_Call{Call: _e.mock.On("GetTxSubmission", ctx, chainID, txHash)} +} + +func (_c *MockRepository_GetTxSubmission_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockRepository_GetTxSubmission_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_GetTxSubmission_Call) Return(txSubmission *TxSubmission, err error) *MockRepository_GetTxSubmission_Call { + _c.Call.Return(txSubmission, err) + return _c +} + +func (_c *MockRepository_GetTxSubmission_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) (*TxSubmission, error)) *MockRepository_GetTxSubmission_Call { + _c.Call.Return(run) + return _c +} + +// LinkPacketTxSubmission provides a mock function for the type MockRepository +func (_mock *MockRepository) LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error { + ret := _mock.Called(ctx, packetID, submissionID) + + if len(ret) == 0 { + panic("no return value specified for LinkPacketTxSubmission") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { + r0 = returnFunc(ctx, packetID, submissionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_LinkPacketTxSubmission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkPacketTxSubmission' +type MockRepository_LinkPacketTxSubmission_Call struct { + *mock.Call +} + +// LinkPacketTxSubmission is a helper method to define mock.On call +// - ctx context.Context +// - packetID int64 +// - submissionID int64 +func (_e *MockRepository_Expecter) LinkPacketTxSubmission(ctx any, packetID any, submissionID any) *MockRepository_LinkPacketTxSubmission_Call { + return &MockRepository_LinkPacketTxSubmission_Call{Call: _e.mock.On("LinkPacketTxSubmission", ctx, packetID, submissionID)} +} + +func (_c *MockRepository_LinkPacketTxSubmission_Call) Run(run func(ctx context.Context, packetID int64, submissionID int64)) *MockRepository_LinkPacketTxSubmission_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int64 + if args[1] != nil { + arg1 = args[1].(int64) + } + var arg2 int64 + if args[2] != nil { + arg2 = args[2].(int64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_LinkPacketTxSubmission_Call) Return(err error) *MockRepository_LinkPacketTxSubmission_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_LinkPacketTxSubmission_Call) RunAndReturn(run func(ctx context.Context, packetID int64, submissionID int64) error) *MockRepository_LinkPacketTxSubmission_Call { + _c.Call.Return(run) + return _c +} + +// ListPackets provides a mock function for the type MockRepository +func (_mock *MockRepository) ListPackets(ctx context.Context, filter ListPacketsFilter) ([]Packet, error) { + ret := _mock.Called(ctx, filter) + + if len(ret) == 0 { + panic("no return value specified for ListPackets") + } + + var r0 []Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ListPacketsFilter) ([]Packet, error)); ok { + return returnFunc(ctx, filter) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ListPacketsFilter) []Packet); ok { + r0 = returnFunc(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ListPacketsFilter) error); ok { + r1 = returnFunc(ctx, filter) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_ListPackets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPackets' +type MockRepository_ListPackets_Call struct { + *mock.Call +} + +// ListPackets is a helper method to define mock.On call +// - ctx context.Context +// - filter ListPacketsFilter +func (_e *MockRepository_Expecter) ListPackets(ctx any, filter any) *MockRepository_ListPackets_Call { + return &MockRepository_ListPackets_Call{Call: _e.mock.On("ListPackets", ctx, filter)} +} + +func (_c *MockRepository_ListPackets_Call) Run(run func(ctx context.Context, filter ListPacketsFilter)) *MockRepository_ListPackets_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ListPacketsFilter + if args[1] != nil { + arg1 = args[1].(ListPacketsFilter) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_ListPackets_Call) Return(packets []Packet, err error) *MockRepository_ListPackets_Call { + _c.Call.Return(packets, err) + return _c +} + +func (_c *MockRepository_ListPackets_Call) RunAndReturn(run func(ctx context.Context, filter ListPacketsFilter) ([]Packet, error)) *MockRepository_ListPackets_Call { + _c.Call.Return(run) + return _c +} + +// ListPacketsBySourceTx provides a mock function for the type MockRepository +func (_mock *MockRepository) ListPacketsBySourceTx(ctx context.Context, chainID string, txHash string) ([]Packet, error) { + ret := _mock.Called(ctx, chainID, txHash) + + if len(ret) == 0 { + panic("no return value specified for ListPacketsBySourceTx") + } + + var r0 []Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]Packet, error)); ok { + return returnFunc(ctx, chainID, txHash) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []Packet); ok { + r0 = returnFunc(ctx, chainID, txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, chainID, txHash) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_ListPacketsBySourceTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPacketsBySourceTx' +type MockRepository_ListPacketsBySourceTx_Call struct { + *mock.Call +} + +// ListPacketsBySourceTx is a helper method to define mock.On call +// - ctx context.Context +// - chainID string +// - txHash string +func (_e *MockRepository_Expecter) ListPacketsBySourceTx(ctx any, chainID any, txHash any) *MockRepository_ListPacketsBySourceTx_Call { + return &MockRepository_ListPacketsBySourceTx_Call{Call: _e.mock.On("ListPacketsBySourceTx", ctx, chainID, txHash)} +} + +func (_c *MockRepository_ListPacketsBySourceTx_Call) Run(run func(ctx context.Context, chainID string, txHash string)) *MockRepository_ListPacketsBySourceTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_ListPacketsBySourceTx_Call) Return(packets []Packet, err error) *MockRepository_ListPacketsBySourceTx_Call { + _c.Call.Return(packets, err) + return _c +} + +func (_c *MockRepository_ListPacketsBySourceTx_Call) RunAndReturn(run func(ctx context.Context, chainID string, txHash string) ([]Packet, error)) *MockRepository_ListPacketsBySourceTx_Call { + _c.Call.Return(run) + return _c +} + +// ListUnfinishedPackets provides a mock function for the type MockRepository +func (_mock *MockRepository) ListUnfinishedPackets(ctx context.Context) ([]Packet, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListUnfinishedPackets") + } + + var r0 []Packet + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]Packet, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []Packet); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]Packet) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_ListUnfinishedPackets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListUnfinishedPackets' +type MockRepository_ListUnfinishedPackets_Call struct { + *mock.Call +} + +// ListUnfinishedPackets is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockRepository_Expecter) ListUnfinishedPackets(ctx any) *MockRepository_ListUnfinishedPackets_Call { + return &MockRepository_ListUnfinishedPackets_Call{Call: _e.mock.On("ListUnfinishedPackets", ctx)} +} + +func (_c *MockRepository_ListUnfinishedPackets_Call) Run(run func(ctx context.Context)) *MockRepository_ListUnfinishedPackets_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockRepository_ListUnfinishedPackets_Call) Return(packets []Packet, err error) *MockRepository_ListUnfinishedPackets_Call { + _c.Call.Return(packets, err) + return _c +} + +func (_c *MockRepository_ListUnfinishedPackets_Call) RunAndReturn(run func(ctx context.Context) ([]Packet, error)) *MockRepository_ListUnfinishedPackets_Call { + _c.Call.Return(run) + return _c +} + +// ResolveTxSubmission provides a mock function for the type MockRepository +func (_mock *MockRepository) ResolveTxSubmission(ctx context.Context, input ResolveTxSubmission) (int64, error) { + ret := _mock.Called(ctx, input) + + if len(ret) == 0 { + panic("no return value specified for ResolveTxSubmission") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ResolveTxSubmission) (int64, error)); ok { + return returnFunc(ctx, input) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ResolveTxSubmission) int64); ok { + r0 = returnFunc(ctx, input) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ResolveTxSubmission) error); ok { + r1 = returnFunc(ctx, input) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepository_ResolveTxSubmission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveTxSubmission' +type MockRepository_ResolveTxSubmission_Call struct { + *mock.Call +} + +// ResolveTxSubmission is a helper method to define mock.On call +// - ctx context.Context +// - input ResolveTxSubmission +func (_e *MockRepository_Expecter) ResolveTxSubmission(ctx any, input any) *MockRepository_ResolveTxSubmission_Call { + return &MockRepository_ResolveTxSubmission_Call{Call: _e.mock.On("ResolveTxSubmission", ctx, input)} +} + +func (_c *MockRepository_ResolveTxSubmission_Call) Run(run func(ctx context.Context, input ResolveTxSubmission)) *MockRepository_ResolveTxSubmission_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 ResolveTxSubmission + if args[1] != nil { + arg1 = args[1].(ResolveTxSubmission) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockRepository_ResolveTxSubmission_Call) Return(n int64, err error) *MockRepository_ResolveTxSubmission_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockRepository_ResolveTxSubmission_Call) RunAndReturn(run func(ctx context.Context, input ResolveTxSubmission) (int64, error)) *MockRepository_ResolveTxSubmission_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketAckTx provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketAckTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error { + ret := _mock.Called(ctx, key, txHash, txTime, relayerAddress) + + if len(ret) == 0 { + panic("no return value specified for SetPacketAckTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, string, time.Time, string) error); ok { + r0 = returnFunc(ctx, key, txHash, txTime, relayerAddress) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketAckTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketAckTx' +type MockRepository_SetPacketAckTx_Call struct { + *mock.Call +} + +// SetPacketAckTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - txHash string +// - txTime time.Time +// - relayerAddress string +func (_e *MockRepository_Expecter) SetPacketAckTx(ctx any, key any, txHash any, txTime any, relayerAddress any) *MockRepository_SetPacketAckTx_Call { + return &MockRepository_SetPacketAckTx_Call{Call: _e.mock.On("SetPacketAckTx", ctx, key, txHash, txTime, relayerAddress)} +} + +func (_c *MockRepository_SetPacketAckTx_Call) Run(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string)) *MockRepository_SetPacketAckTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Time + if args[3] != nil { + arg3 = args[3].(time.Time) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketAckTx_Call) Return(err error) *MockRepository_SetPacketAckTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketAckTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error) *MockRepository_SetPacketAckTx_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketRecvTx provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketRecvTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error { + ret := _mock.Called(ctx, key, txHash, txTime, relayerAddress) + + if len(ret) == 0 { + panic("no return value specified for SetPacketRecvTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, string, time.Time, string) error); ok { + r0 = returnFunc(ctx, key, txHash, txTime, relayerAddress) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketRecvTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketRecvTx' +type MockRepository_SetPacketRecvTx_Call struct { + *mock.Call +} + +// SetPacketRecvTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - txHash string +// - txTime time.Time +// - relayerAddress string +func (_e *MockRepository_Expecter) SetPacketRecvTx(ctx any, key any, txHash any, txTime any, relayerAddress any) *MockRepository_SetPacketRecvTx_Call { + return &MockRepository_SetPacketRecvTx_Call{Call: _e.mock.On("SetPacketRecvTx", ctx, key, txHash, txTime, relayerAddress)} +} + +func (_c *MockRepository_SetPacketRecvTx_Call) Run(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string)) *MockRepository_SetPacketRecvTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Time + if args[3] != nil { + arg3 = args[3].(time.Time) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketRecvTx_Call) Return(err error) *MockRepository_SetPacketRecvTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketRecvTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error) *MockRepository_SetPacketRecvTx_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketSourceTxFinalizedTime provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketSourceTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + ret := _mock.Called(ctx, key, t) + + if len(ret) == 0 { + panic("no return value specified for SetPacketSourceTxFinalizedTime") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, time.Time) error); ok { + r0 = returnFunc(ctx, key, t) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketSourceTxFinalizedTime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketSourceTxFinalizedTime' +type MockRepository_SetPacketSourceTxFinalizedTime_Call struct { + *mock.Call +} + +// SetPacketSourceTxFinalizedTime is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - t time.Time +func (_e *MockRepository_Expecter) SetPacketSourceTxFinalizedTime(ctx any, key any, t any) *MockRepository_SetPacketSourceTxFinalizedTime_Call { + return &MockRepository_SetPacketSourceTxFinalizedTime_Call{Call: _e.mock.On("SetPacketSourceTxFinalizedTime", ctx, key, t)} +} + +func (_c *MockRepository_SetPacketSourceTxFinalizedTime_Call) Run(run func(ctx context.Context, key PacketKey, t time.Time)) *MockRepository_SetPacketSourceTxFinalizedTime_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 time.Time + if args[2] != nil { + arg2 = args[2].(time.Time) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketSourceTxFinalizedTime_Call) Return(err error) *MockRepository_SetPacketSourceTxFinalizedTime_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketSourceTxFinalizedTime_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, t time.Time) error) *MockRepository_SetPacketSourceTxFinalizedTime_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketTimeoutTx provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketTimeoutTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error { + ret := _mock.Called(ctx, key, txHash, txTime, relayerAddress) + + if len(ret) == 0 { + panic("no return value specified for SetPacketTimeoutTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, string, time.Time, string) error); ok { + r0 = returnFunc(ctx, key, txHash, txTime, relayerAddress) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketTimeoutTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketTimeoutTx' +type MockRepository_SetPacketTimeoutTx_Call struct { + *mock.Call +} + +// SetPacketTimeoutTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - txHash string +// - txTime time.Time +// - relayerAddress string +func (_e *MockRepository_Expecter) SetPacketTimeoutTx(ctx any, key any, txHash any, txTime any, relayerAddress any) *MockRepository_SetPacketTimeoutTx_Call { + return &MockRepository_SetPacketTimeoutTx_Call{Call: _e.mock.On("SetPacketTimeoutTx", ctx, key, txHash, txTime, relayerAddress)} +} + +func (_c *MockRepository_SetPacketTimeoutTx_Call) Run(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string)) *MockRepository_SetPacketTimeoutTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Time + if args[3] != nil { + arg3 = args[3].(time.Time) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketTimeoutTx_Call) Return(err error) *MockRepository_SetPacketTimeoutTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketTimeoutTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, relayerAddress string) error) *MockRepository_SetPacketTimeoutTx_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketWriteAckStatus provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketWriteAckStatus(ctx context.Context, key PacketKey, status WriteAckStatus) error { + ret := _mock.Called(ctx, key, status) + + if len(ret) == 0 { + panic("no return value specified for SetPacketWriteAckStatus") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, WriteAckStatus) error); ok { + r0 = returnFunc(ctx, key, status) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketWriteAckStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketWriteAckStatus' +type MockRepository_SetPacketWriteAckStatus_Call struct { + *mock.Call +} + +// SetPacketWriteAckStatus is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - status WriteAckStatus +func (_e *MockRepository_Expecter) SetPacketWriteAckStatus(ctx any, key any, status any) *MockRepository_SetPacketWriteAckStatus_Call { + return &MockRepository_SetPacketWriteAckStatus_Call{Call: _e.mock.On("SetPacketWriteAckStatus", ctx, key, status)} +} + +func (_c *MockRepository_SetPacketWriteAckStatus_Call) Run(run func(ctx context.Context, key PacketKey, status WriteAckStatus)) *MockRepository_SetPacketWriteAckStatus_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 WriteAckStatus + if args[2] != nil { + arg2 = args[2].(WriteAckStatus) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckStatus_Call) Return(err error) *MockRepository_SetPacketWriteAckStatus_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckStatus_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, status WriteAckStatus) error) *MockRepository_SetPacketWriteAckStatus_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketWriteAckTx provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketWriteAckTx(ctx context.Context, key PacketKey, txHash string, txTime time.Time, status WriteAckStatus) error { + ret := _mock.Called(ctx, key, txHash, txTime, status) + + if len(ret) == 0 { + panic("no return value specified for SetPacketWriteAckTx") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, string, time.Time, WriteAckStatus) error); ok { + r0 = returnFunc(ctx, key, txHash, txTime, status) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketWriteAckTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketWriteAckTx' +type MockRepository_SetPacketWriteAckTx_Call struct { + *mock.Call +} + +// SetPacketWriteAckTx is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - txHash string +// - txTime time.Time +// - status WriteAckStatus +func (_e *MockRepository_Expecter) SetPacketWriteAckTx(ctx any, key any, txHash any, txTime any, status any) *MockRepository_SetPacketWriteAckTx_Call { + return &MockRepository_SetPacketWriteAckTx_Call{Call: _e.mock.On("SetPacketWriteAckTx", ctx, key, txHash, txTime, status)} +} + +func (_c *MockRepository_SetPacketWriteAckTx_Call) Run(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, status WriteAckStatus)) *MockRepository_SetPacketWriteAckTx_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Time + if args[3] != nil { + arg3 = args[3].(time.Time) + } + var arg4 WriteAckStatus + if args[4] != nil { + arg4 = args[4].(WriteAckStatus) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckTx_Call) Return(err error) *MockRepository_SetPacketWriteAckTx_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckTx_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, txHash string, txTime time.Time, status WriteAckStatus) error) *MockRepository_SetPacketWriteAckTx_Call { + _c.Call.Return(run) + return _c +} + +// SetPacketWriteAckTxFinalizedTime provides a mock function for the type MockRepository +func (_mock *MockRepository) SetPacketWriteAckTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + ret := _mock.Called(ctx, key, t) + + if len(ret) == 0 { + panic("no return value specified for SetPacketWriteAckTxFinalizedTime") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, time.Time) error); ok { + r0 = returnFunc(ctx, key, t) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetPacketWriteAckTxFinalizedTime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPacketWriteAckTxFinalizedTime' +type MockRepository_SetPacketWriteAckTxFinalizedTime_Call struct { + *mock.Call +} + +// SetPacketWriteAckTxFinalizedTime is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - t time.Time +func (_e *MockRepository_Expecter) SetPacketWriteAckTxFinalizedTime(ctx any, key any, t any) *MockRepository_SetPacketWriteAckTxFinalizedTime_Call { + return &MockRepository_SetPacketWriteAckTxFinalizedTime_Call{Call: _e.mock.On("SetPacketWriteAckTxFinalizedTime", ctx, key, t)} +} + +func (_c *MockRepository_SetPacketWriteAckTxFinalizedTime_Call) Run(run func(ctx context.Context, key PacketKey, t time.Time)) *MockRepository_SetPacketWriteAckTxFinalizedTime_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 time.Time + if args[2] != nil { + arg2 = args[2].(time.Time) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckTxFinalizedTime_Call) Return(err error) *MockRepository_SetPacketWriteAckTxFinalizedTime_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetPacketWriteAckTxFinalizedTime_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, t time.Time) error) *MockRepository_SetPacketWriteAckTxFinalizedTime_Call { + _c.Call.Return(run) + return _c +} + +// SetRelayCursor provides a mock function for the type MockRepository +func (_mock *MockRepository) SetRelayCursor(ctx context.Context, cursorKey string, height uint64) error { + ret := _mock.Called(ctx, cursorKey, height) + + if len(ret) == 0 { + panic("no return value specified for SetRelayCursor") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint64) error); ok { + r0 = returnFunc(ctx, cursorKey, height) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_SetRelayCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRelayCursor' +type MockRepository_SetRelayCursor_Call struct { + *mock.Call +} + +// SetRelayCursor is a helper method to define mock.On call +// - ctx context.Context +// - cursorKey string +// - height uint64 +func (_e *MockRepository_Expecter) SetRelayCursor(ctx any, cursorKey any, height any) *MockRepository_SetRelayCursor_Call { + return &MockRepository_SetRelayCursor_Call{Call: _e.mock.On("SetRelayCursor", ctx, cursorKey, height)} +} + +func (_c *MockRepository_SetRelayCursor_Call) Run(run func(ctx context.Context, cursorKey string, height uint64)) *MockRepository_SetRelayCursor_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 uint64 + if args[2] != nil { + arg2 = args[2].(uint64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_SetRelayCursor_Call) Return(err error) *MockRepository_SetRelayCursor_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_SetRelayCursor_Call) RunAndReturn(run func(ctx context.Context, cursorKey string, height uint64) error) *MockRepository_SetRelayCursor_Call { + _c.Call.Return(run) + return _c +} + +// UpdatePacketStatus provides a mock function for the type MockRepository +func (_mock *MockRepository) UpdatePacketStatus(ctx context.Context, key PacketKey, status RelayStatus) error { + ret := _mock.Called(ctx, key, status) + + if len(ret) == 0 { + panic("no return value specified for UpdatePacketStatus") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, PacketKey, RelayStatus) error); ok { + r0 = returnFunc(ctx, key, status) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepository_UpdatePacketStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePacketStatus' +type MockRepository_UpdatePacketStatus_Call struct { + *mock.Call +} + +// UpdatePacketStatus is a helper method to define mock.On call +// - ctx context.Context +// - key PacketKey +// - status RelayStatus +func (_e *MockRepository_Expecter) UpdatePacketStatus(ctx any, key any, status any) *MockRepository_UpdatePacketStatus_Call { + return &MockRepository_UpdatePacketStatus_Call{Call: _e.mock.On("UpdatePacketStatus", ctx, key, status)} +} + +func (_c *MockRepository_UpdatePacketStatus_Call) Run(run func(ctx context.Context, key PacketKey, status RelayStatus)) *MockRepository_UpdatePacketStatus_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 PacketKey + if args[1] != nil { + arg1 = args[1].(PacketKey) + } + var arg2 RelayStatus + if args[2] != nil { + arg2 = args[2].(RelayStatus) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepository_UpdatePacketStatus_Call) Return(err error) *MockRepository_UpdatePacketStatus_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepository_UpdatePacketStatus_Call) RunAndReturn(run func(ctx context.Context, key PacketKey, status RelayStatus) error) *MockRepository_UpdatePacketStatus_Call { _c.Call.Return(run) return _c } diff --git a/link/internal/store/store_postgres.go b/link/internal/store/store_postgres.go index f2004f3c3..29c027097 100644 --- a/link/internal/store/store_postgres.go +++ b/link/internal/store/store_postgres.go @@ -220,6 +220,422 @@ func (db *PostgresDB) ListPacketsBySourceTx( return packets, nil } +func (db *PostgresDB) GetPacket(ctx context.Context, key PacketKey) (*Packet, error) { + db.logger.Debug( + "GetPacket", + "chainID", + key.SourceChainID, + "clientID", + key.PacketSourceClientID, + "sequence", + key.PacketSequenceNumber, + ) + + if err := key.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packet key") + } + + row, err := db.repo.GetPacket(ctx, postgres.GetPacketParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) + if err != nil { + return nil, errNormalize(err) + } + + packet := packetFromPostgres(row) + + return &packet, nil +} + +func (db *PostgresDB) ListUnfinishedPackets(ctx context.Context) ([]Packet, error) { + db.logger.Debug("ListUnfinishedPackets") + + rows, err := db.repo.ListUnfinishedPackets(ctx) + if err != nil { + return nil, errNormalize(err) + } + + packets := make([]Packet, len(rows)) + for i, row := range rows { + packets[i] = packetFromPostgres(row) + } + + return packets, nil +} + +func (db *PostgresDB) ListPackets(ctx context.Context, filter ListPacketsFilter) ([]Packet, error) { + db.logger.Debug("ListPackets") + + if err := filter.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packets filter") + } + + chainArg, clientArg := listPacketsArgs(filter) + + rows, err := db.repo.ListPackets(ctx, chainArg, clientArg) + if err != nil { + return nil, errNormalize(err) + } + + packets := make([]Packet, len(rows)) + for i, row := range rows { + packets[i] = packetFromPostgres(row) + } + + return packets, nil +} + +func (db *PostgresDB) CountPacketSubmissions(ctx context.Context, filter ListPacketsFilter) (map[int64]int64, error) { + db.logger.Debug("CountPacketSubmissions") + + if err := filter.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packets filter") + } + + chainArg, clientArg := listPacketsArgs(filter) + + rows, err := db.repo.CountPacketSubmissions(ctx, chainArg, clientArg) + if err != nil { + return nil, errNormalize(err) + } + + counts := make(map[int64]int64, len(rows)) + for _, row := range rows { + counts[row.PacketID] = row.Attempts + } + + return counts, nil +} + +func (db *PostgresDB) UpdatePacketStatus(ctx context.Context, key PacketKey, status RelayStatus) error { + db.logger.Debug( + "UpdatePacketStatus", + "chainID", + key.SourceChainID, + "sequence", + key.PacketSequenceNumber, + "status", + status, + ) + + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + if status == "" { + return errors.New("status is required") + } + + return db.repo.UpdatePacketStatus(ctx, postgres.UpdatePacketStatusParams{ + Status: string(status), + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketRecvTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + return db.repo.SetPacketRecvTx(ctx, postgres.SetPacketRecvTxParams{ + RecvTxHash: &txHash, + RecvTxTime: pgTimestamp(txTime), + RecvTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketAckTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + return db.repo.SetPacketAckTx(ctx, postgres.SetPacketAckTxParams{ + AckTxHash: &txHash, + AckTxTime: pgTimestamp(txTime), + AckTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketTimeoutTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + return db.repo.SetPacketTimeoutTx(ctx, postgres.SetPacketTimeoutTxParams{ + TimeoutTxHash: &txHash, + TimeoutTxTime: pgTimestamp(txTime), + TimeoutTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketWriteAckTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + status WriteAckStatus, +) error { + if err := validateSetWriteAckTx(key, txHash, txTime, status); err != nil { + return err + } + + statusStr := string(status) + + return db.repo.SetPacketWriteAckTx(ctx, postgres.SetPacketWriteAckTxParams{ + WriteAckTxHash: &txHash, + WriteAckTxTime: pgTimestamp(txTime), + WriteAckStatus: &statusStr, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketWriteAckStatus(ctx context.Context, key PacketKey, status WriteAckStatus) error { + if err := validateSetWriteAckStatus(key, status); err != nil { + return err + } + + statusStr := string(status) + + return db.repo.SetPacketWriteAckStatus(ctx, postgres.SetPacketWriteAckStatusParams{ + WriteAckStatus: &statusStr, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) ClearPacketRecvTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketRecvTx(ctx, postgres.ClearPacketRecvTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) ClearPacketAckTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketAckTx(ctx, postgres.ClearPacketAckTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) ClearPacketTimeoutTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketTimeoutTx(ctx, postgres.ClearPacketTimeoutTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketSourceTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + if err := validateSetFinalized(key, t); err != nil { + return err + } + + return db.repo.SetPacketSourceTxFinalizedTime(ctx, postgres.SetPacketSourceTxFinalizedTimeParams{ + SourceTxFinalizedTime: pgTimestamp(t), + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) SetPacketWriteAckTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + if err := validateSetFinalized(key, t); err != nil { + return err + } + + return db.repo.SetPacketWriteAckTxFinalizedTime(ctx, postgres.SetPacketWriteAckTxFinalizedTimeParams{ + WriteAckTxFinalizedTime: pgTimestamp(t), + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *PostgresDB) CreateTxSubmission(ctx context.Context, input CreateTxSubmission) (int64, error) { + db.logger.Debug("CreateTxSubmission", "chainID", input.ChainID, "txHash", input.TxHash, "txType", input.TxType) + + if err := input.Validate(); err != nil { + return 0, errors.Wrap(err, "invalid tx submission") + } + + return db.repo.CreateTxSubmission(ctx, postgres.CreateTxSubmissionParams{ + TxHash: input.TxHash, + ChainID: input.ChainID, + TxType: string(input.TxType), + RelayerAddress: input.RelayerAddress, + SubmittedAt: pgTimestamp(input.SubmittedAt), + Status: string(TxSubmissionStatusPending), + }) +} + +func (db *PostgresDB) LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error { + db.logger.Debug("LinkPacketTxSubmission", "packetID", packetID, "submissionID", submissionID) + + if packetID == 0 || submissionID == 0 { + return errors.New("packet id and submission id are required") + } + + return db.repo.LinkPacketTxSubmission(ctx, packetID, submissionID) +} + +func (db *PostgresDB) GetTxSubmission(ctx context.Context, chainID string, txHash string) (*TxSubmission, error) { + db.logger.Debug("GetTxSubmission", "chainID", chainID, "txHash", txHash) + + if chainID == "" || txHash == "" { + return nil, errors.New("chain id and tx hash are required") + } + + row, err := db.repo.GetTxSubmission(ctx, chainID, txHash) + if err != nil { + return nil, errNormalize(err) + } + + submission := txSubmissionFromPostgres(row) + + return &submission, nil +} + +func (db *PostgresDB) ResolveTxSubmission(ctx context.Context, input ResolveTxSubmission) (int64, error) { + db.logger.Debug("ResolveTxSubmission", "chainID", input.ChainID, "txHash", input.TxHash, "status", input.Status) + + if err := input.Validate(); err != nil { + return 0, err + } + + gasCostNumeric, err := numericFromStringPtr(input.GasCost) + if err != nil { + return 0, errors.Wrap(err, "invalid gas cost") + } + + return db.repo.ResolveTxSubmission(ctx, postgres.ResolveTxSubmissionParams{ + Status: string(input.Status), + GasCostAmount: gasCostNumeric, + ExecutionError: input.ExecutionError, + ResolvedAt: pgTimestamp(input.ResolvedAt), + ChainID: input.ChainID, + TxHash: input.TxHash, + }) +} + +func (db *PostgresDB) GetRelayCursor(ctx context.Context, cursorKey string) (uint64, error) { + db.logger.Debug("GetRelayCursor", "cursorKey", cursorKey) + + if cursorKey == "" { + return 0, errors.New("cursor key is required") + } + + height, err := db.repo.GetRelayCursor(ctx, cursorKey) + if err != nil { + return 0, errNormalize(err) + } + + return uint64(height), nil //nolint:gosec // heights are non-negative +} + +func (db *PostgresDB) SetRelayCursor(ctx context.Context, cursorKey string, height uint64) error { + db.logger.Debug("SetRelayCursor", "cursorKey", cursorKey, "height", height) + + if cursorKey == "" { + return errors.New("cursor key is required") + } + + return db.repo.SetRelayCursor(ctx, cursorKey, int64(height)) //nolint:gosec // heights fit in int64 +} + +func txSubmissionFromPostgres(row postgres.RelayerTxSubmission) TxSubmission { + return TxSubmission{ + ID: row.ID, + TxHash: row.TxHash, + ChainID: row.ChainID, + TxType: TxType(row.TxType), + RelayerAddress: row.RelayerAddress, + SubmittedAt: row.SubmittedAt.Time.UTC(), + ResolvedAt: pgTimePtr(row.ResolvedAt), + GasCostAmount: numericPtr(row.GasCostAmount), + Status: TxSubmissionStatus(row.Status), + ExecutionError: row.ExecutionError, + } +} + +func numericFromStringPtr(s *string) (pgtype.Numeric, error) { + var n pgtype.Numeric + if s == nil { + return n, nil + } + + if err := n.Scan(*s); err != nil { + return n, err + } + + return n, nil +} + +func numericPtr(n pgtype.Numeric) *string { + if !n.Valid { + return nil + } + + value, err := n.Value() + if err != nil { + return nil + } + + s, ok := value.(string) + if !ok { + return nil + } + + return &s +} + func packetFromPostgres(row postgres.Packet) Packet { return Packet{ ID: row.ID, @@ -253,6 +669,9 @@ func packetFromPostgres(row postgres.Packet) Packet { TimeoutTxHash: row.TimeoutTxHash, TimeoutTxTime: pgTimePtr(row.TimeoutTxTime), TimeoutTxRelayerAddress: row.TimeoutTxRelayerAddress, + + SourceTxFinalizedTime: pgTimePtr(row.SourceTxFinalizedTime), + WriteAckTxFinalizedTime: pgTimePtr(row.WriteAckTxFinalizedTime), } } diff --git a/link/internal/store/store_sqlite.go b/link/internal/store/store_sqlite.go index f244f0c5e..48fb14a2a 100644 --- a/link/internal/store/store_sqlite.go +++ b/link/internal/store/store_sqlite.go @@ -3,9 +3,11 @@ package store import ( "context" "database/sql" + "fmt" "log/slog" "net/url" "path/filepath" + "sort" "time" "github.com/pkg/errors" @@ -34,11 +36,21 @@ var _ Store = (*SqliteDB)(nil) func DefaultSqliteConnOptions() map[string]string { return map[string]string{ - "journal_mode": "WAL", // Write-Ahead Logging mode - "mode": "rwc", // read, write, create file + "journal_mode": "WAL", // Write-Ahead Logging mode + "busy_timeout": "5000", // wait up to 5s on a locked database instead of failing + "mode": "rwc", // read, write, create file } } +// sqlitePragmas are connection options the modernc driver only honors as +// _pragma=name(value) query values, not as plain parameters. +var sqlitePragmas = map[string]bool{ + "journal_mode": true, + "busy_timeout": true, + "foreign_keys": true, + "synchronous": true, +} + func NewSqlite(path string) (*SqliteDB, error) { return NewSqliteWithOptions(path, DefaultSqliteConnOptions()) } @@ -224,6 +236,399 @@ func (db *SqliteDB) ListPacketsBySourceTx( return packets, nil } +func (db *SqliteDB) GetPacket(ctx context.Context, key PacketKey) (*Packet, error) { + db.logger.Debug( + "GetPacket", + "chainID", + key.SourceChainID, + "clientID", + key.PacketSourceClientID, + "sequence", + key.PacketSequenceNumber, + ) + + if err := key.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packet key") + } + + row, err := db.repo.GetPacket(ctx, reposqlite.GetPacketParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) + if err != nil { + return nil, errNormalize(err) + } + + packet := packetFromSqlite(row) + + return &packet, nil +} + +func (db *SqliteDB) ListUnfinishedPackets(ctx context.Context) ([]Packet, error) { + db.logger.Debug("ListUnfinishedPackets") + + rows, err := db.repo.ListUnfinishedPackets(ctx) + if err != nil { + return nil, errNormalize(err) + } + + packets := make([]Packet, len(rows)) + for i, row := range rows { + packets[i] = packetFromSqlite(row) + } + + return packets, nil +} + +func (db *SqliteDB) ListPackets(ctx context.Context, filter ListPacketsFilter) ([]Packet, error) { + db.logger.Debug("ListPackets") + + if err := filter.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packets filter") + } + + chainArg, clientArg := listPacketsArgs(filter) + + rows, err := db.repo.ListPackets(ctx, chainArg, clientArg) + if err != nil { + return nil, errNormalize(err) + } + + packets := make([]Packet, len(rows)) + for i, row := range rows { + packets[i] = packetFromSqlite(row) + } + + return packets, nil +} + +func (db *SqliteDB) CountPacketSubmissions(ctx context.Context, filter ListPacketsFilter) (map[int64]int64, error) { + db.logger.Debug("CountPacketSubmissions") + + if err := filter.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid packets filter") + } + + chainArg, clientArg := listPacketsArgs(filter) + + rows, err := db.repo.CountPacketSubmissions(ctx, chainArg, clientArg) + if err != nil { + return nil, errNormalize(err) + } + + counts := make(map[int64]int64, len(rows)) + for _, row := range rows { + counts[row.PacketID] = row.Attempts + } + + return counts, nil +} + +func (db *SqliteDB) UpdatePacketStatus(ctx context.Context, key PacketKey, status RelayStatus) error { + db.logger.Debug( + "UpdatePacketStatus", + "chainID", + key.SourceChainID, + "sequence", + key.PacketSequenceNumber, + "status", + status, + ) + + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + if status == "" { + return errors.New("status is required") + } + + return db.repo.UpdatePacketStatus(ctx, reposqlite.UpdatePacketStatusParams{ + Status: string(status), + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketRecvTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + txTimeUTC := txTime.UTC() + + return db.repo.SetPacketRecvTx(ctx, reposqlite.SetPacketRecvTxParams{ + RecvTxHash: &txHash, + RecvTxTime: &txTimeUTC, + RecvTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketAckTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + txTimeUTC := txTime.UTC() + + return db.repo.SetPacketAckTx(ctx, reposqlite.SetPacketAckTxParams{ + AckTxHash: &txHash, + AckTxTime: &txTimeUTC, + AckTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketTimeoutTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + relayerAddress string, +) error { + if err := validateSetTx(key, txHash, txTime, relayerAddress); err != nil { + return err + } + + txTimeUTC := txTime.UTC() + + return db.repo.SetPacketTimeoutTx(ctx, reposqlite.SetPacketTimeoutTxParams{ + TimeoutTxHash: &txHash, + TimeoutTxTime: &txTimeUTC, + TimeoutTxRelayerAddress: &relayerAddress, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketWriteAckTx( + ctx context.Context, + key PacketKey, + txHash string, + txTime time.Time, + status WriteAckStatus, +) error { + if err := validateSetWriteAckTx(key, txHash, txTime, status); err != nil { + return err + } + + statusStr := string(status) + txTimeUTC := txTime.UTC() + + return db.repo.SetPacketWriteAckTx(ctx, reposqlite.SetPacketWriteAckTxParams{ + WriteAckTxHash: &txHash, + WriteAckTxTime: &txTimeUTC, + WriteAckStatus: &statusStr, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketWriteAckStatus(ctx context.Context, key PacketKey, status WriteAckStatus) error { + if err := validateSetWriteAckStatus(key, status); err != nil { + return err + } + + statusStr := string(status) + + return db.repo.SetPacketWriteAckStatus(ctx, reposqlite.SetPacketWriteAckStatusParams{ + WriteAckStatus: &statusStr, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) ClearPacketRecvTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketRecvTx(ctx, reposqlite.ClearPacketRecvTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) ClearPacketAckTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketAckTx(ctx, reposqlite.ClearPacketAckTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) ClearPacketTimeoutTx(ctx context.Context, key PacketKey) error { + if err := key.Validate(); err != nil { + return errors.Wrap(err, "invalid packet key") + } + + return db.repo.ClearPacketTimeoutTx(ctx, reposqlite.ClearPacketTimeoutTxParams{ + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketSourceTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + if err := validateSetFinalized(key, t); err != nil { + return err + } + + tUTC := t.UTC() + + return db.repo.SetPacketSourceTxFinalizedTime(ctx, reposqlite.SetPacketSourceTxFinalizedTimeParams{ + SourceTxFinalizedTime: &tUTC, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) SetPacketWriteAckTxFinalizedTime(ctx context.Context, key PacketKey, t time.Time) error { + if err := validateSetFinalized(key, t); err != nil { + return err + } + + tUTC := t.UTC() + + return db.repo.SetPacketWriteAckTxFinalizedTime(ctx, reposqlite.SetPacketWriteAckTxFinalizedTimeParams{ + WriteAckTxFinalizedTime: &tUTC, + SourceChainID: key.SourceChainID, + PacketSequenceNumber: int64(key.PacketSequenceNumber), //nolint:gosec // sequences fit in int64 + PacketSourceClientID: key.PacketSourceClientID, + }) +} + +func (db *SqliteDB) CreateTxSubmission(ctx context.Context, input CreateTxSubmission) (int64, error) { + db.logger.Debug("CreateTxSubmission", "chainID", input.ChainID, "txHash", input.TxHash, "txType", input.TxType) + + if err := input.Validate(); err != nil { + return 0, errors.Wrap(err, "invalid tx submission") + } + + return db.repo.CreateTxSubmission(ctx, reposqlite.CreateTxSubmissionParams{ + TxHash: input.TxHash, + ChainID: input.ChainID, + TxType: string(input.TxType), + RelayerAddress: input.RelayerAddress, + SubmittedAt: input.SubmittedAt.UTC(), + Status: string(TxSubmissionStatusPending), + }) +} + +func (db *SqliteDB) LinkPacketTxSubmission(ctx context.Context, packetID int64, submissionID int64) error { + db.logger.Debug("LinkPacketTxSubmission", "packetID", packetID, "submissionID", submissionID) + + if packetID == 0 || submissionID == 0 { + return errors.New("packet id and submission id are required") + } + + return db.repo.LinkPacketTxSubmission(ctx, packetID, submissionID) +} + +func (db *SqliteDB) GetTxSubmission(ctx context.Context, chainID string, txHash string) (*TxSubmission, error) { + db.logger.Debug("GetTxSubmission", "chainID", chainID, "txHash", txHash) + + if chainID == "" || txHash == "" { + return nil, errors.New("chain id and tx hash are required") + } + + row, err := db.repo.GetTxSubmission(ctx, chainID, txHash) + if err != nil { + return nil, errNormalize(err) + } + + submission := txSubmissionFromSqlite(row) + + return &submission, nil +} + +func (db *SqliteDB) ResolveTxSubmission(ctx context.Context, input ResolveTxSubmission) (int64, error) { + db.logger.Debug("ResolveTxSubmission", "chainID", input.ChainID, "txHash", input.TxHash, "status", input.Status) + + if err := input.Validate(); err != nil { + return 0, err + } + + resolvedAtUTC := input.ResolvedAt.UTC() + + return db.repo.ResolveTxSubmission(ctx, reposqlite.ResolveTxSubmissionParams{ + Status: string(input.Status), + GasCostAmount: input.GasCost, + ExecutionError: input.ExecutionError, + ResolvedAt: &resolvedAtUTC, + ChainID: input.ChainID, + TxHash: input.TxHash, + }) +} + +func (db *SqliteDB) GetRelayCursor(ctx context.Context, cursorKey string) (uint64, error) { + db.logger.Debug("GetRelayCursor", "cursorKey", cursorKey) + + if cursorKey == "" { + return 0, errors.New("cursor key is required") + } + + height, err := db.repo.GetRelayCursor(ctx, cursorKey) + if err != nil { + return 0, errNormalize(err) + } + + return uint64(height), nil //nolint:gosec // heights are non-negative +} + +func (db *SqliteDB) SetRelayCursor(ctx context.Context, cursorKey string, height uint64) error { + db.logger.Debug("SetRelayCursor", "cursorKey", cursorKey, "height", height) + + if cursorKey == "" { + return errors.New("cursor key is required") + } + + return db.repo.SetRelayCursor(ctx, cursorKey, int64(height)) //nolint:gosec // heights fit in int64 +} + +func txSubmissionFromSqlite(row reposqlite.RelayerTxSubmission) TxSubmission { + return TxSubmission{ + ID: row.ID, + TxHash: row.TxHash, + ChainID: row.ChainID, + TxType: TxType(row.TxType), + RelayerAddress: row.RelayerAddress, + SubmittedAt: row.SubmittedAt.UTC(), + ResolvedAt: utcTimePtr(row.ResolvedAt), + GasCostAmount: row.GasCostAmount, + Status: TxSubmissionStatus(row.Status), + ExecutionError: row.ExecutionError, + } +} + func packetFromSqlite(row reposqlite.Packet) Packet { return Packet{ ID: row.ID, @@ -257,6 +662,9 @@ func packetFromSqlite(row reposqlite.Packet) Packet { TimeoutTxHash: row.TimeoutTxHash, TimeoutTxTime: utcTimePtr(row.TimeoutTxTime), TimeoutTxRelayerAddress: row.TimeoutTxRelayerAddress, + + SourceTxFinalizedTime: utcTimePtr(row.SourceTxFinalizedTime), + WriteAckTxFinalizedTime: utcTimePtr(row.WriteAckTxFinalizedTime), } } @@ -296,10 +704,22 @@ func sqliteURL(path string, connectionOpts map[string]string) (string, error) { query := u.Query() // sqlite does not enforce foreign keys unless enabled per connection - query.Set("_pragma", "foreign_keys(1)") + query.Add("_pragma", "foreign_keys(1)") + + // Sort for a deterministic connection string. + keys := make([]string, 0, len(connectionOpts)) + for k := range connectionOpts { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if sqlitePragmas[k] { + query.Add("_pragma", fmt.Sprintf("%s(%s)", k, connectionOpts[k])) + continue + } - for k, v := range connectionOpts { - query.Set(k, v) + query.Set(k, connectionOpts[k]) } u.RawQuery = query.Encode() diff --git a/link/internal/store/store_test.go b/link/internal/store/store_test.go index 850f6167d..6da88dee4 100644 --- a/link/internal/store/store_test.go +++ b/link/internal/store/store_test.go @@ -276,4 +276,598 @@ func testRepoReadWrite(t *testing.T, s Store) { require.NoError(t, err) assert.Empty(t, packets) }) + + t.Run("packetLifecycle", func(t *testing.T) { + testPacketLifecycle(t, s) + }) + + t.Run("txSubmissions", func(t *testing.T) { + testTxSubmissions(t, s) + }) + + t.Run("countSubmissions", func(t *testing.T) { + testCountPacketSubmissions(t, s) + }) + + t.Run("relayCursors", func(t *testing.T) { + testRelayCursors(t, s) + }) +} + +// newLifecyclePacket returns a fresh packet with a unique sequence number so +// lifecycle sub-tests don't collide across shared stores. +func newLifecyclePacket(seq uint64) CreatePacket { + return CreatePacket{ + Status: RelayStatusPending, + SourceChainID: chainIDBase, + DestinationChainID: chainIDEth, + SourceTxHash: "0xlifecycle", + SourceTxTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC), + PacketSequenceNumber: seq, + PacketSourceClientID: "ethereum-0", + PacketDestinationClientID: "base-0", + PacketTimeoutTimestamp: time.Date(2026, 7, 8, 13, 0, 0, 0, time.UTC), + } +} + +func testPacketLifecycle(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + + create := newLifecyclePacket(100) + key := PacketKey{ + SourceChainID: create.SourceChainID, + PacketSequenceNumber: create.PacketSequenceNumber, + PacketSourceClientID: create.PacketSourceClientID, + } + + // GetPacket returns ErrNotFound before the packet exists + _, err := s.GetPacket(ctx, key) + require.ErrorIs(t, err, ErrNotFound) + + // Invalid key is rejected + _, err = s.GetPacket(ctx, PacketKey{}) + require.ErrorContains(t, err, "source chain id is required") + + require.NoError(t, s.CreatePacket(ctx, create)) + + // GetPacket round-trips the natural key and returns an id + got, err := s.GetPacket(ctx, key) + require.NoError(t, err) + assert.NotZero(t, got.ID) + assert.Equal(t, RelayStatusPending, got.Status) + assert.Equal(t, create.PacketSequenceNumber, got.PacketSequenceNumber) + assert.Nil(t, got.RecvTxHash) + assert.Nil(t, got.SourceTxFinalizedTime) + + // UpdatePacketStatus moves the status and bumps updated_at + require.NoError(t, s.UpdatePacketStatus(ctx, key, RelayStatusDeliverRecvPacket)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + assert.Equal(t, RelayStatusDeliverRecvPacket, got.Status) + + // SetPacketRecvTx / ClearPacketRecvTx retry cycle + recvTime := time.Date(2026, 7, 8, 12, 30, 0, 0, time.UTC) + require.NoError(t, s.SetPacketRecvTx(ctx, key, "0xrecv", recvTime, "relayer-recv")) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.RecvTxHash) + assert.Equal(t, "0xrecv", *got.RecvTxHash) + require.NotNil(t, got.RecvTxTime) + assert.Equal(t, recvTime, *got.RecvTxTime) + require.NotNil(t, got.RecvTxRelayerAddress) + assert.Equal(t, "relayer-recv", *got.RecvTxRelayerAddress) + + require.NoError(t, s.ClearPacketRecvTx(ctx, key)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + assert.Nil(t, got.RecvTxHash) + assert.Nil(t, got.RecvTxTime) + // relayer address is intentionally retained by the clear (only hash+time nulled) + + // re-set after clear (retry succeeds) + require.NoError(t, s.SetPacketRecvTx(ctx, key, "0xrecv2", recvTime, "relayer-recv")) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.RecvTxHash) + assert.Equal(t, "0xrecv2", *got.RecvTxHash) + + // SetPacketWriteAckTx records the dest writeAcknowledgement tx and its status. + writeAckTime := time.Date(2026, 7, 8, 12, 40, 0, 0, time.UTC) + require.NoError(t, s.SetPacketWriteAckTx(ctx, key, "0xwriteack", writeAckTime, WriteAckStatusSuccess)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.WriteAckTxHash) + assert.Equal(t, "0xwriteack", *got.WriteAckTxHash) + require.NotNil(t, got.WriteAckStatus) + assert.Equal(t, WriteAckStatusSuccess, *got.WriteAckStatus) + + // SetPacketWriteAckStatus changes only the classification, leaving the + // write-ack tx columns untouched (the source-gone path, where the true dest + // writeAcknowledgement tx is unknown). + require.NoError(t, s.SetPacketWriteAckStatus(ctx, key, WriteAckStatusError)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.WriteAckStatus) + assert.Equal(t, WriteAckStatusError, *got.WriteAckStatus) + require.NotNil(t, got.WriteAckTxHash) + assert.Equal(t, "0xwriteack", *got.WriteAckTxHash) + require.NotNil(t, got.WriteAckTxTime) + assert.Equal(t, writeAckTime, *got.WriteAckTxTime) + + // SetPacketAckTx + ClearPacketAckTx + ackTime := time.Date(2026, 7, 8, 12, 50, 0, 0, time.UTC) + require.NoError(t, s.SetPacketAckTx(ctx, key, "0xack", ackTime, "relayer-ack")) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.AckTxHash) + assert.Equal(t, "0xack", *got.AckTxHash) + require.NoError(t, s.ClearPacketAckTx(ctx, key)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + assert.Nil(t, got.AckTxHash) + + // SetPacketTimeoutTx + ClearPacketTimeoutTx + timeoutTime := time.Date(2026, 7, 8, 13, 30, 0, 0, time.UTC) + require.NoError(t, s.SetPacketTimeoutTx(ctx, key, "0xtimeout", timeoutTime, "relayer-timeout")) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.TimeoutTxHash) + assert.Equal(t, "0xtimeout", *got.TimeoutTxHash) + require.NoError(t, s.ClearPacketTimeoutTx(ctx, key)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + assert.Nil(t, got.TimeoutTxHash) + + // Finalized times + finalized := time.Date(2026, 7, 8, 12, 35, 0, 0, time.UTC) + require.NoError(t, s.SetPacketSourceTxFinalizedTime(ctx, key, finalized)) + require.NoError(t, s.SetPacketWriteAckTxFinalizedTime(ctx, key, finalized)) + got, err = s.GetPacket(ctx, key) + require.NoError(t, err) + require.NotNil(t, got.SourceTxFinalizedTime) + assert.Equal(t, finalized, *got.SourceTxFinalizedTime) + require.NotNil(t, got.WriteAckTxFinalizedTime) + assert.Equal(t, finalized, *got.WriteAckTxFinalizedTime) + + // Set-tx validation: empty hash rejected + require.ErrorContains(t, s.SetPacketRecvTx(ctx, key, "", recvTime, "r"), "tx hash is required") + + t.Run("listUnfinished", func(t *testing.T) { + // An unfinished packet (still DELIVER_RECV_PACKET) is listed + unfinished, err := s.ListUnfinishedPackets(ctx) + require.NoError(t, err) + assert.True(t, containsSeq(unfinished, key.PacketSequenceNumber), "expected unfinished packet to be listed") + + // A terminal packet is excluded + termCreate := newLifecyclePacket(101) + termKey := PacketKey{ + SourceChainID: termCreate.SourceChainID, + PacketSequenceNumber: termCreate.PacketSequenceNumber, + PacketSourceClientID: termCreate.PacketSourceClientID, + } + require.NoError(t, s.CreatePacket(ctx, termCreate)) + + for _, terminal := range []RelayStatus{ + RelayStatusCompleteWithAck, + RelayStatusCompleteWithTimeout, + RelayStatusFailed, + RelayStatusFailedInvariant, + RelayStatusCompleteWithWriteAckSuccess, + RelayStatusCompleteWithWriteAckError, + } { + require.NoError(t, s.UpdatePacketStatus(ctx, termKey, terminal)) + unfinished, err = s.ListUnfinishedPackets(ctx) + require.NoError(t, err) + assert.False(t, containsSeq(unfinished, termKey.PacketSequenceNumber), + "terminal packet %s must be excluded", terminal) + } + }) + + t.Run("listPacketsFilter", func(t *testing.T) { + // Filter by source client id + clientID := key.PacketSourceClientID + byClient, err := s.ListPackets(ctx, ListPacketsFilter{SourceClientID: &clientID}) + require.NoError(t, err) + assert.True(t, containsSeq(byClient, key.PacketSequenceNumber)) + + // Filter by an unknown client returns empty + unknown := "nonexistent-99" + empty, err := s.ListPackets(ctx, ListPacketsFilter{SourceClientID: &unknown}) + require.NoError(t, err) + assert.Empty(t, empty) + + // No filter returns everything (non-empty here) + all, err := s.ListPackets(ctx, ListPacketsFilter{}) + require.NoError(t, err) + assert.NotEmpty(t, all) + + // A non-nil pointer to an empty string is rejected rather than silently + // matching zero rows. + blank := "" + _, err = s.ListPackets(ctx, ListPacketsFilter{SourceChainID: &blank}) + require.ErrorContains(t, err, "source chain id filter must not be empty") + + _, err = s.ListPackets(ctx, ListPacketsFilter{SourceClientID: &blank}) + require.ErrorContains(t, err, "source client id filter must not be empty") + }) +} + +func testTxSubmissions(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + + // Invalid submission rejected + _, err := s.CreateTxSubmission(ctx, CreateTxSubmission{}) + require.ErrorContains(t, err, "tx hash is required") + + submittedAt := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC) + id, err := s.CreateTxSubmission(ctx, CreateTxSubmission{ + TxHash: "0xsubmission", + ChainID: chainIDEth, + TxType: TxTypeRecvPacket, + RelayerAddress: "relayer-1", + SubmittedAt: submittedAt, + }) + require.NoError(t, err) + assert.NotZero(t, id) + + // LinkPacketTxSubmission joins a packet to the submission + linkCreate := newLifecyclePacket(200) + linkKey := PacketKey{ + SourceChainID: linkCreate.SourceChainID, + PacketSequenceNumber: linkCreate.PacketSequenceNumber, + PacketSourceClientID: linkCreate.PacketSourceClientID, + } + require.NoError(t, s.CreatePacket(ctx, linkCreate)) + packet, err := s.GetPacket(ctx, linkKey) + require.NoError(t, err) + + require.NoError(t, s.LinkPacketTxSubmission(ctx, packet.ID, id)) + // Idempotent link (ON CONFLICT DO NOTHING) + require.NoError(t, s.LinkPacketTxSubmission(ctx, packet.ID, id)) + + // Linking with a missing packet id violates the FK + require.Error(t, s.LinkPacketTxSubmission(ctx, 9_999_999, id)) + + // GetTxSubmission returns the freshly created row in its PENDING state. + got, err := s.GetTxSubmission(ctx, chainIDEth, "0xsubmission") + require.NoError(t, err) + assert.Equal(t, id, got.ID) + assert.Equal(t, chainIDEth, got.ChainID) + assert.Equal(t, TxTypeRecvPacket, got.TxType) + assert.Equal(t, TxSubmissionStatusPending, got.Status) + assert.Equal(t, submittedAt, got.SubmittedAt) + assert.Nil(t, got.ResolvedAt) + assert.Nil(t, got.GasCostAmount) + assert.Nil(t, got.ExecutionError) + + // Unknown (chain, hash) is ErrNotFound. + _, err = s.GetTxSubmission(ctx, chainIDEth, "0xunknown") + require.ErrorIs(t, err, ErrNotFound) + + testTxSubmissionResolve(t, s) + testTxSubmissionResolveIdempotent(t, s) + testTxSubmissionResolveValidation(t, s) +} + +// testTxSubmissionResolve resolves a pending submission into each terminal state +// and asserts the stored gas/error/resolved-at round-trip (nil gas for EXPIRED). +func testTxSubmissionResolve(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + gas := "42000000000000" + execErr := "transaction reverted" + resolvedAt := time.Date(2026, 7, 9, 11, 0, 0, 0, time.UTC) + + cases := []struct { + name string + txHash string + status TxSubmissionStatus + gasCost *string + execErr *string + }{ + {"success", "0xsuccess", TxSubmissionStatusSuccess, &gas, nil}, + {"failed", "0xreverted", TxSubmissionStatusFailed, &gas, &execErr}, + {"expired", "0xexpired", TxSubmissionStatusExpired, nil, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.CreateTxSubmission(ctx, CreateTxSubmission{ + TxHash: tc.txHash, + ChainID: chainIDEth, + TxType: TxTypeAckPacket, + RelayerAddress: "relayer-1", + SubmittedAt: time.Date(2026, 7, 9, 10, 30, 0, 0, time.UTC), + }) + require.NoError(t, err) + + rows, err := s.ResolveTxSubmission(ctx, ResolveTxSubmission{ + ChainID: chainIDEth, + TxHash: tc.txHash, + Status: tc.status, + GasCost: tc.gasCost, + ExecutionError: tc.execErr, + ResolvedAt: resolvedAt, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), rows, "a pending row transitions") + + got, err := s.GetTxSubmission(ctx, chainIDEth, tc.txHash) + require.NoError(t, err) + assert.Equal(t, tc.status, got.Status) + require.NotNil(t, got.ResolvedAt) + assert.Equal(t, resolvedAt, *got.ResolvedAt) + assert.Equal(t, tc.gasCost, got.GasCostAmount) + assert.Equal(t, tc.execErr, got.ExecutionError) + }) + } +} + +// testTxSubmissionResolveIdempotent asserts re-resolution is a no-op that cannot +// clobber an already-resolved row (the WHERE status = 'PENDING' guard). +func testTxSubmissionResolveIdempotent(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + gas := "21000000000000" + resolvedAt := time.Date(2026, 7, 9, 11, 0, 0, 0, time.UTC) + + _, err := s.CreateTxSubmission(ctx, CreateTxSubmission{ + TxHash: "0xidempotent", + ChainID: chainIDEth, + TxType: TxTypeAckPacket, + RelayerAddress: "relayer-1", + SubmittedAt: time.Date(2026, 7, 9, 10, 30, 0, 0, time.UTC), + }) + require.NoError(t, err) + + rows, err := s.ResolveTxSubmission(ctx, ResolveTxSubmission{ + ChainID: chainIDEth, TxHash: "0xidempotent", + Status: TxSubmissionStatusSuccess, GasCost: &gas, ResolvedAt: resolvedAt, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + + newGas := "999" + rows, err = s.ResolveTxSubmission(ctx, ResolveTxSubmission{ + ChainID: chainIDEth, TxHash: "0xidempotent", + Status: TxSubmissionStatusFailed, GasCost: &newGas, ResolvedAt: resolvedAt.Add(time.Minute), + }) + require.NoError(t, err) + assert.Equal(t, int64(0), rows, "an already-resolved row is a no-op") + + got, err := s.GetTxSubmission(ctx, chainIDEth, "0xidempotent") + require.NoError(t, err) + assert.Equal(t, TxSubmissionStatusSuccess, got.Status, "resolved row must not be clobbered") + require.NotNil(t, got.GasCostAmount) + assert.Equal(t, gas, *got.GasCostAmount) +} + +// testTxSubmissionResolveValidation covers the input validation on +// ResolveTxSubmission, including the unified terminal-status and gas-cost checks. +func testTxSubmissionResolveValidation(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + resolvedAt := time.Date(2026, 7, 9, 13, 0, 0, 0, time.UTC) + malformedGas := "not-a-number" + negativeGas := "-5" + + cases := []struct { + name string + input ResolveTxSubmission + want string + }{ + { + "missing chain id", + ResolveTxSubmission{TxHash: "0xhash", Status: TxSubmissionStatusSuccess, ResolvedAt: resolvedAt}, + "chain id is required", + }, + { + "missing tx hash", + ResolveTxSubmission{ChainID: chainIDEth, Status: TxSubmissionStatusSuccess, ResolvedAt: resolvedAt}, + "tx hash is required", + }, + { + "missing status", + ResolveTxSubmission{ChainID: chainIDEth, TxHash: "0xhash", ResolvedAt: resolvedAt}, + "terminal status is required", + }, + { + "pending status rejected", + ResolveTxSubmission{ChainID: chainIDEth, TxHash: "0xhash", Status: TxSubmissionStatusPending, ResolvedAt: resolvedAt}, + "terminal status is required", + }, + { + "missing resolved at", + ResolveTxSubmission{ChainID: chainIDEth, TxHash: "0xhash", Status: TxSubmissionStatusSuccess}, + "resolved at is required", + }, + { + "malformed gas cost", + ResolveTxSubmission{ChainID: chainIDEth, TxHash: "0xhash", Status: TxSubmissionStatusSuccess, GasCost: &malformedGas, ResolvedAt: resolvedAt}, + "gas cost must be a non-negative integer", + }, + { + "negative gas cost", + ResolveTxSubmission{ChainID: chainIDEth, TxHash: "0xhash", Status: TxSubmissionStatusSuccess, GasCost: &negativeGas, ResolvedAt: resolvedAt}, + "gas cost must be a non-negative integer", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.ResolveTxSubmission(ctx, tc.input) + require.ErrorContains(t, err, tc.want) + }) + } + + // Resolving an unknown (chain, hash) matches zero rows: a no-op, not an error. + rows, err := s.ResolveTxSubmission(ctx, ResolveTxSubmission{ + ChainID: chainIDEth, TxHash: "0xmissing", Status: TxSubmissionStatusSuccess, ResolvedAt: resolvedAt, + }) + require.NoError(t, err) + assert.Equal(t, int64(0), rows, "no matching row transitions") + + // GetTxSubmission validates its key too. + _, err = s.GetTxSubmission(ctx, "", "0xhash") + require.ErrorContains(t, err, "chain id and tx hash are required") +} + +// testCountPacketSubmissions pins the additive attempts aggregate query: it +// counts linked tx submissions per packet, returns zero-attempt packets as +// absent (not a zero row), and honors the same (source chain, source client) +// filter as ListPackets so `ibc status` can scope one route. +func testCountPacketSubmissions(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + + const ( + clientA = "client-a" + clientB = "client-b" + ) + txTime := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + + // seedPacket creates a packet on (chain, client) with the given sequence and + // returns its assigned id. + seedPacket := func(chainID, clientID string, seq uint64) int64 { + create := CreatePacket{ + Status: RelayStatusPending, + SourceChainID: chainID, + DestinationChainID: chainIDBase, + SourceTxHash: "0xsource", + SourceTxTime: txTime, + PacketSequenceNumber: seq, + PacketSourceClientID: clientID, + PacketDestinationClientID: "dest", + PacketTimeoutTimestamp: txTime.Add(time.Hour), + } + require.NoError(t, s.CreatePacket(ctx, create)) + + p, err := s.GetPacket(ctx, PacketKey{ + SourceChainID: chainID, + PacketSequenceNumber: seq, + PacketSourceClientID: clientID, + }) + require.NoError(t, err) + + return p.ID + } + + // link records a fresh tx submission and links it to a packet. + link := func(packetID int64, txHash string) { + id, err := s.CreateTxSubmission(ctx, CreateTxSubmission{ + TxHash: txHash, + ChainID: chainIDEth, + TxType: TxTypeRecvPacket, + RelayerAddress: "relayer", + SubmittedAt: txTime, + }) + require.NoError(t, err) + require.NoError(t, s.LinkPacketTxSubmission(ctx, packetID, id)) + } + + // packetTwoAttempts: two distinct submissions -> attempts 2. + packetTwoAttempts := seedPacket(chainIDEth, clientA, 501) + link(packetTwoAttempts, "0xcount-a1") + link(packetTwoAttempts, "0xcount-a2") + + // packetOneAttempt with an idempotent (duplicate) link -> attempts 1. + packetOneAttempt := seedPacket(chainIDEth, clientA, 502) + subID, err := s.CreateTxSubmission(ctx, CreateTxSubmission{ + TxHash: "0xcount-a3", + ChainID: chainIDEth, + TxType: TxTypeRecvPacket, + RelayerAddress: "relayer", + SubmittedAt: txTime, + }) + require.NoError(t, err) + require.NoError(t, s.LinkPacketTxSubmission(ctx, packetOneAttempt, subID)) + require.NoError(t, s.LinkPacketTxSubmission(ctx, packetOneAttempt, subID)) // ON CONFLICT DO NOTHING + + // packetNoAttempts: never submitted -> absent from the map. + packetNoAttempts := seedPacket(chainIDEth, clientA, 503) + + // A packet on a different client, to prove the filter scopes correctly. + otherClientPacket := seedPacket(chainIDEth, clientB, 504) + link(otherClientPacket, "0xcount-b1") + + // Unfiltered: every packet with >=1 submission appears with its exact count. + all, err := s.CountPacketSubmissions(ctx, ListPacketsFilter{}) + require.NoError(t, err) + assert.Equal(t, int64(2), all[packetTwoAttempts]) + assert.Equal(t, int64(1), all[packetOneAttempt]) + assert.Equal(t, int64(1), all[otherClientPacket]) + _, present := all[packetNoAttempts] + assert.False(t, present, "packet with no submissions must be absent, not a zero row") + + // Client-scoped: only clientA's packets are counted. + chainID := chainIDEth + cid := clientA + scoped, err := s.CountPacketSubmissions(ctx, ListPacketsFilter{ + SourceChainID: &chainID, + SourceClientID: &cid, + }) + require.NoError(t, err) + assert.Equal(t, int64(2), scoped[packetTwoAttempts]) + assert.Equal(t, int64(1), scoped[packetOneAttempt]) + _, present = scoped[otherClientPacket] + assert.False(t, present, "filter must exclude the other client's packet") + + // An empty-string filter is rejected the same way ListPackets rejects it. + empty := "" + _, err = s.CountPacketSubmissions(ctx, ListPacketsFilter{SourceChainID: &empty}) + require.Error(t, err) +} + +func testRelayCursors(t *testing.T, s Store) { + t.Helper() + + ctx := context.Background() + + // Cursor keys are per (source chain, source client): "/". + keyEth := chainIDEth + "/clientA" + keyBase := chainIDBase + "/clientB" + + // Absent cursor returns ErrNotFound + _, err := s.GetRelayCursor(ctx, keyEth) + require.ErrorIs(t, err, ErrNotFound) + + // Empty cursor key rejected + _, err = s.GetRelayCursor(ctx, "") + require.ErrorContains(t, err, "cursor key is required") + + // Upsert insert + require.NoError(t, s.SetRelayCursor(ctx, keyEth, 42)) + height, err := s.GetRelayCursor(ctx, keyEth) + require.NoError(t, err) + assert.Equal(t, uint64(42), height) + + // Upsert update (same PK) + require.NoError(t, s.SetRelayCursor(ctx, keyEth, 100)) + height, err = s.GetRelayCursor(ctx, keyEth) + require.NoError(t, err) + assert.Equal(t, uint64(100), height) + + // A second key is independent + require.NoError(t, s.SetRelayCursor(ctx, keyBase, 7)) + height, err = s.GetRelayCursor(ctx, keyBase) + require.NoError(t, err) + assert.Equal(t, uint64(7), height) +} + +func containsSeq(packets []Packet, seq uint64) bool { + for _, p := range packets { + if p.PacketSequenceNumber == seq { + return true + } + } + + return false } diff --git a/link/internal/types/v2/attestor/aliases.go b/link/internal/types/v2/attestor/aliases.go new file mode 100644 index 000000000..3d51b19de --- /dev/null +++ b/link/internal/types/v2/attestor/aliases.go @@ -0,0 +1,29 @@ +package attestor + +// The generated attestor proto/connect types now live in the public +// github.com/cosmos/ibc/link/api/v2/attestor package. These aliases let the +// mockery-generated client mock in this package keep referencing them by their +// bare names without pulling testify into the public API package. + +import attestorv2 "github.com/cosmos/ibc/link/api/v2/attestor" + +// AttestationServiceClient re-exports the public attestor service client interface. +type AttestationServiceClient = attestorv2.AttestationServiceClient + +// LatestAttestableHeightRequest re-exports the public request type. +type LatestAttestableHeightRequest = attestorv2.LatestAttestableHeightRequest + +// LatestAttestableHeightResponse re-exports the public response type. +type LatestAttestableHeightResponse = attestorv2.LatestAttestableHeightResponse + +// PacketAttestationRequest re-exports the public request type. +type PacketAttestationRequest = attestorv2.PacketAttestationRequest + +// PacketAttestationResponse re-exports the public response type. +type PacketAttestationResponse = attestorv2.PacketAttestationResponse + +// StateAttestationRequest re-exports the public request type. +type StateAttestationRequest = attestorv2.StateAttestationRequest + +// StateAttestationResponse re-exports the public response type. +type StateAttestationResponse = attestorv2.StateAttestationResponse diff --git a/link/internal/types/v2/attestor/attestor.mock.go b/link/internal/types/v2/attestor/attestor.mock.go index ca89ca292..b1fe1eb34 100644 --- a/link/internal/types/v2/attestor/attestor.mock.go +++ b/link/internal/types/v2/attestor/attestor.mock.go @@ -7,6 +7,7 @@ package attestor import ( "connectrpc.com/connect" "context" + "github.com/cosmos/ibc/link/api/v2/attestor" mock "github.com/stretchr/testify/mock" ) @@ -38,26 +39,26 @@ func (_m *MockAttestationServiceClient) EXPECT() *MockAttestationServiceClient_E } // LatestAttestableHeight provides a mock function for the type MockAttestationServiceClient -func (_mock *MockAttestationServiceClient) LatestAttestableHeight(context1 context.Context, request *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error) { +func (_mock *MockAttestationServiceClient) LatestAttestableHeight(context1 context.Context, request *connect.Request[attestor.LatestAttestableHeightRequest]) (*connect.Response[attestor.LatestAttestableHeightResponse], error) { ret := _mock.Called(context1, request) if len(ret) == 0 { panic("no return value specified for LatestAttestableHeight") } - var r0 *connect.Response[LatestAttestableHeightResponse] + var r0 *connect.Response[attestor.LatestAttestableHeightResponse] var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.LatestAttestableHeightRequest]) (*connect.Response[attestor.LatestAttestableHeightResponse], error)); ok { return returnFunc(context1, request) } - if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[LatestAttestableHeightRequest]) *connect.Response[LatestAttestableHeightResponse]); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.LatestAttestableHeightRequest]) *connect.Response[attestor.LatestAttestableHeightResponse]); ok { r0 = returnFunc(context1, request) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*connect.Response[LatestAttestableHeightResponse]) + r0 = ret.Get(0).(*connect.Response[attestor.LatestAttestableHeightResponse]) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[LatestAttestableHeightRequest]) error); ok { + if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[attestor.LatestAttestableHeightRequest]) error); ok { r1 = returnFunc(context1, request) } else { r1 = ret.Error(1) @@ -72,20 +73,20 @@ type MockAttestationServiceClient_LatestAttestableHeight_Call struct { // LatestAttestableHeight is a helper method to define mock.On call // - context1 context.Context -// - request *connect.Request[LatestAttestableHeightRequest] +// - request *connect.Request[attestor.LatestAttestableHeightRequest] func (_e *MockAttestationServiceClient_Expecter) LatestAttestableHeight(context1 any, request any) *MockAttestationServiceClient_LatestAttestableHeight_Call { return &MockAttestationServiceClient_LatestAttestableHeight_Call{Call: _e.mock.On("LatestAttestableHeight", context1, request)} } -func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) Run(run func(context1 context.Context, request *connect.Request[LatestAttestableHeightRequest])) *MockAttestationServiceClient_LatestAttestableHeight_Call { +func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) Run(run func(context1 context.Context, request *connect.Request[attestor.LatestAttestableHeightRequest])) *MockAttestationServiceClient_LatestAttestableHeight_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 *connect.Request[LatestAttestableHeightRequest] + var arg1 *connect.Request[attestor.LatestAttestableHeightRequest] if args[1] != nil { - arg1 = args[1].(*connect.Request[LatestAttestableHeightRequest]) + arg1 = args[1].(*connect.Request[attestor.LatestAttestableHeightRequest]) } run( arg0, @@ -95,12 +96,148 @@ func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) Run(run func return _c } -func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) Return(response *connect.Response[LatestAttestableHeightResponse], err error) *MockAttestationServiceClient_LatestAttestableHeight_Call { +func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) Return(response *connect.Response[attestor.LatestAttestableHeightResponse], err error) *MockAttestationServiceClient_LatestAttestableHeight_Call { _c.Call.Return(response, err) return _c } -func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[LatestAttestableHeightRequest]) (*connect.Response[LatestAttestableHeightResponse], error)) *MockAttestationServiceClient_LatestAttestableHeight_Call { +func (_c *MockAttestationServiceClient_LatestAttestableHeight_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[attestor.LatestAttestableHeightRequest]) (*connect.Response[attestor.LatestAttestableHeightResponse], error)) *MockAttestationServiceClient_LatestAttestableHeight_Call { + _c.Call.Return(run) + return _c +} + +// PacketAttestation provides a mock function for the type MockAttestationServiceClient +func (_mock *MockAttestationServiceClient) PacketAttestation(context1 context.Context, request *connect.Request[attestor.PacketAttestationRequest]) (*connect.Response[attestor.PacketAttestationResponse], error) { + ret := _mock.Called(context1, request) + + if len(ret) == 0 { + panic("no return value specified for PacketAttestation") + } + + var r0 *connect.Response[attestor.PacketAttestationResponse] + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.PacketAttestationRequest]) (*connect.Response[attestor.PacketAttestationResponse], error)); ok { + return returnFunc(context1, request) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.PacketAttestationRequest]) *connect.Response[attestor.PacketAttestationResponse]); ok { + r0 = returnFunc(context1, request) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*connect.Response[attestor.PacketAttestationResponse]) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[attestor.PacketAttestationRequest]) error); ok { + r1 = returnFunc(context1, request) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockAttestationServiceClient_PacketAttestation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PacketAttestation' +type MockAttestationServiceClient_PacketAttestation_Call struct { + *mock.Call +} + +// PacketAttestation is a helper method to define mock.On call +// - context1 context.Context +// - request *connect.Request[attestor.PacketAttestationRequest] +func (_e *MockAttestationServiceClient_Expecter) PacketAttestation(context1 any, request any) *MockAttestationServiceClient_PacketAttestation_Call { + return &MockAttestationServiceClient_PacketAttestation_Call{Call: _e.mock.On("PacketAttestation", context1, request)} +} + +func (_c *MockAttestationServiceClient_PacketAttestation_Call) Run(run func(context1 context.Context, request *connect.Request[attestor.PacketAttestationRequest])) *MockAttestationServiceClient_PacketAttestation_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *connect.Request[attestor.PacketAttestationRequest] + if args[1] != nil { + arg1 = args[1].(*connect.Request[attestor.PacketAttestationRequest]) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAttestationServiceClient_PacketAttestation_Call) Return(response *connect.Response[attestor.PacketAttestationResponse], err error) *MockAttestationServiceClient_PacketAttestation_Call { + _c.Call.Return(response, err) + return _c +} + +func (_c *MockAttestationServiceClient_PacketAttestation_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[attestor.PacketAttestationRequest]) (*connect.Response[attestor.PacketAttestationResponse], error)) *MockAttestationServiceClient_PacketAttestation_Call { + _c.Call.Return(run) + return _c +} + +// StateAttestation provides a mock function for the type MockAttestationServiceClient +func (_mock *MockAttestationServiceClient) StateAttestation(context1 context.Context, request *connect.Request[attestor.StateAttestationRequest]) (*connect.Response[attestor.StateAttestationResponse], error) { + ret := _mock.Called(context1, request) + + if len(ret) == 0 { + panic("no return value specified for StateAttestation") + } + + var r0 *connect.Response[attestor.StateAttestationResponse] + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.StateAttestationRequest]) (*connect.Response[attestor.StateAttestationResponse], error)); ok { + return returnFunc(context1, request) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[attestor.StateAttestationRequest]) *connect.Response[attestor.StateAttestationResponse]); ok { + r0 = returnFunc(context1, request) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*connect.Response[attestor.StateAttestationResponse]) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[attestor.StateAttestationRequest]) error); ok { + r1 = returnFunc(context1, request) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockAttestationServiceClient_StateAttestation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StateAttestation' +type MockAttestationServiceClient_StateAttestation_Call struct { + *mock.Call +} + +// StateAttestation is a helper method to define mock.On call +// - context1 context.Context +// - request *connect.Request[attestor.StateAttestationRequest] +func (_e *MockAttestationServiceClient_Expecter) StateAttestation(context1 any, request any) *MockAttestationServiceClient_StateAttestation_Call { + return &MockAttestationServiceClient_StateAttestation_Call{Call: _e.mock.On("StateAttestation", context1, request)} +} + +func (_c *MockAttestationServiceClient_StateAttestation_Call) Run(run func(context1 context.Context, request *connect.Request[attestor.StateAttestationRequest])) *MockAttestationServiceClient_StateAttestation_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *connect.Request[attestor.StateAttestationRequest] + if args[1] != nil { + arg1 = args[1].(*connect.Request[attestor.StateAttestationRequest]) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAttestationServiceClient_StateAttestation_Call) Return(response *connect.Response[attestor.StateAttestationResponse], err error) *MockAttestationServiceClient_StateAttestation_Call { + _c.Call.Return(response, err) + return _c +} + +func (_c *MockAttestationServiceClient_StateAttestation_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[attestor.StateAttestationRequest]) (*connect.Response[attestor.StateAttestationResponse], error)) *MockAttestationServiceClient_StateAttestation_Call { _c.Call.Return(run) return _c } diff --git a/link/internal/types/v2/attestor/attestor.pb.go b/link/internal/types/v2/attestor/attestor.pb.go deleted file mode 100644 index 7cb085d4b..000000000 --- a/link/internal/types/v2/attestor/attestor.pb.go +++ /dev/null @@ -1,173 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: attestor.proto - -package attestor - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type LatestAttestableHeightRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Attestor string `protobuf:"bytes,1,opt,name=attestor,proto3" json:"attestor,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LatestAttestableHeightRequest) Reset() { - *x = LatestAttestableHeightRequest{} - mi := &file_attestor_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LatestAttestableHeightRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LatestAttestableHeightRequest) ProtoMessage() {} - -func (x *LatestAttestableHeightRequest) ProtoReflect() protoreflect.Message { - mi := &file_attestor_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LatestAttestableHeightRequest.ProtoReflect.Descriptor instead. -func (*LatestAttestableHeightRequest) Descriptor() ([]byte, []int) { - return file_attestor_proto_rawDescGZIP(), []int{0} -} - -func (x *LatestAttestableHeightRequest) GetAttestor() string { - if x != nil { - return x.Attestor - } - return "" -} - -type LatestAttestableHeightResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LatestAttestableHeightResponse) Reset() { - *x = LatestAttestableHeightResponse{} - mi := &file_attestor_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LatestAttestableHeightResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LatestAttestableHeightResponse) ProtoMessage() {} - -func (x *LatestAttestableHeightResponse) ProtoReflect() protoreflect.Message { - mi := &file_attestor_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LatestAttestableHeightResponse.ProtoReflect.Descriptor instead. -func (*LatestAttestableHeightResponse) Descriptor() ([]byte, []int) { - return file_attestor_proto_rawDescGZIP(), []int{1} -} - -func (x *LatestAttestableHeightResponse) GetHeight() uint64 { - if x != nil { - return x.Height - } - return 0 -} - -var File_attestor_proto protoreflect.FileDescriptor - -const file_attestor_proto_rawDesc = "" + - "\n" + - "\x0eattestor.proto\x12\x0fibc.v2.attestor\";\n" + - "\x1dLatestAttestableHeightRequest\x12\x1a\n" + - "\battestor\x18\x01 \x01(\tR\battestor\"8\n" + - "\x1eLatestAttestableHeightResponse\x12\x16\n" + - "\x06height\x18\x01 \x01(\x04R\x06height2\x8f\x01\n" + - "\x12AttestationService\x12y\n" + - "\x16LatestAttestableHeight\x12..ibc.v2.attestor.LatestAttestableHeightRequest\x1a/.ibc.v2.attestor.LatestAttestableHeightResponseB7Z5github.com/cosmos/ibc/link/internal/types/v2/attestorb\x06proto3" - -var ( - file_attestor_proto_rawDescOnce sync.Once - file_attestor_proto_rawDescData []byte -) - -func file_attestor_proto_rawDescGZIP() []byte { - file_attestor_proto_rawDescOnce.Do(func() { - file_attestor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_attestor_proto_rawDesc), len(file_attestor_proto_rawDesc))) - }) - return file_attestor_proto_rawDescData -} - -var file_attestor_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_attestor_proto_goTypes = []any{ - (*LatestAttestableHeightRequest)(nil), // 0: ibc.v2.attestor.LatestAttestableHeightRequest - (*LatestAttestableHeightResponse)(nil), // 1: ibc.v2.attestor.LatestAttestableHeightResponse -} -var file_attestor_proto_depIdxs = []int32{ - 0, // 0: ibc.v2.attestor.AttestationService.LatestAttestableHeight:input_type -> ibc.v2.attestor.LatestAttestableHeightRequest - 1, // 1: ibc.v2.attestor.AttestationService.LatestAttestableHeight:output_type -> ibc.v2.attestor.LatestAttestableHeightResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_attestor_proto_init() } -func file_attestor_proto_init() { - if File_attestor_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_attestor_proto_rawDesc), len(file_attestor_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_attestor_proto_goTypes, - DependencyIndexes: file_attestor_proto_depIdxs, - MessageInfos: file_attestor_proto_msgTypes, - }.Build() - File_attestor_proto = out.File - file_attestor_proto_goTypes = nil - file_attestor_proto_depIdxs = nil -} diff --git a/link/internal/types/v2/relayer/relayer.mock.go b/link/internal/types/v2/relayer/relayer.mock.go deleted file mode 100644 index 9dcb947cc..000000000 --- a/link/internal/types/v2/relayer/relayer.mock.go +++ /dev/null @@ -1,106 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package relayer - -import ( - "connectrpc.com/connect" - "context" - mock "github.com/stretchr/testify/mock" -) - -// NewMockRelayerApiServiceClient creates a new instance of MockRelayerApiServiceClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockRelayerApiServiceClient(t interface { - mock.TestingT - Cleanup(func()) -}) *MockRelayerApiServiceClient { - mock := &MockRelayerApiServiceClient{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockRelayerApiServiceClient is an autogenerated mock type for the RelayerApiServiceClient type -type MockRelayerApiServiceClient struct { - mock.Mock -} - -type MockRelayerApiServiceClient_Expecter struct { - mock *mock.Mock -} - -func (_m *MockRelayerApiServiceClient) EXPECT() *MockRelayerApiServiceClient_Expecter { - return &MockRelayerApiServiceClient_Expecter{mock: &_m.Mock} -} - -// Relay provides a mock function for the type MockRelayerApiServiceClient -func (_mock *MockRelayerApiServiceClient) Relay(context1 context.Context, request *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error) { - ret := _mock.Called(context1, request) - - if len(ret) == 0 { - panic("no return value specified for Relay") - } - - var r0 *connect.Response[RelayResponse] - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error)); ok { - return returnFunc(context1, request) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, *connect.Request[RelayRequest]) *connect.Response[RelayResponse]); ok { - r0 = returnFunc(context1, request) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*connect.Response[RelayResponse]) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, *connect.Request[RelayRequest]) error); ok { - r1 = returnFunc(context1, request) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockRelayerApiServiceClient_Relay_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Relay' -type MockRelayerApiServiceClient_Relay_Call struct { - *mock.Call -} - -// Relay is a helper method to define mock.On call -// - context1 context.Context -// - request *connect.Request[RelayRequest] -func (_e *MockRelayerApiServiceClient_Expecter) Relay(context1 any, request any) *MockRelayerApiServiceClient_Relay_Call { - return &MockRelayerApiServiceClient_Relay_Call{Call: _e.mock.On("Relay", context1, request)} -} - -func (_c *MockRelayerApiServiceClient_Relay_Call) Run(run func(context1 context.Context, request *connect.Request[RelayRequest])) *MockRelayerApiServiceClient_Relay_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 *connect.Request[RelayRequest] - if args[1] != nil { - arg1 = args[1].(*connect.Request[RelayRequest]) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockRelayerApiServiceClient_Relay_Call) Return(response *connect.Response[RelayResponse], err error) *MockRelayerApiServiceClient_Relay_Call { - _c.Call.Return(response, err) - return _c -} - -func (_c *MockRelayerApiServiceClient_Relay_Call) RunAndReturn(run func(context1 context.Context, request *connect.Request[RelayRequest]) (*connect.Response[RelayResponse], error)) *MockRelayerApiServiceClient_Relay_Call { - _c.Call.Return(run) - return _c -} diff --git a/link/internal/types/v2/relayer/relayer.pb.go b/link/internal/types/v2/relayer/relayer.pb.go deleted file mode 100644 index 999e7a002..000000000 --- a/link/internal/types/v2/relayer/relayer.pb.go +++ /dev/null @@ -1,173 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: relayer.proto - -package relayer - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type RelayRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` - ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RelayRequest) Reset() { - *x = RelayRequest{} - mi := &file_relayer_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RelayRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RelayRequest) ProtoMessage() {} - -func (x *RelayRequest) ProtoReflect() protoreflect.Message { - mi := &file_relayer_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RelayRequest.ProtoReflect.Descriptor instead. -func (*RelayRequest) Descriptor() ([]byte, []int) { - return file_relayer_proto_rawDescGZIP(), []int{0} -} - -func (x *RelayRequest) GetTxHash() string { - if x != nil { - return x.TxHash - } - return "" -} - -func (x *RelayRequest) GetChainId() string { - if x != nil { - return x.ChainId - } - return "" -} - -type RelayResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RelayResponse) Reset() { - *x = RelayResponse{} - mi := &file_relayer_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RelayResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RelayResponse) ProtoMessage() {} - -func (x *RelayResponse) ProtoReflect() protoreflect.Message { - mi := &file_relayer_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RelayResponse.ProtoReflect.Descriptor instead. -func (*RelayResponse) Descriptor() ([]byte, []int) { - return file_relayer_proto_rawDescGZIP(), []int{1} -} - -var File_relayer_proto protoreflect.FileDescriptor - -const file_relayer_proto_rawDesc = "" + - "\n" + - "\rrelayer.proto\x12\x0eibc.v2.relayer\"B\n" + - "\fRelayRequest\x12\x17\n" + - "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + - "\bchain_id\x18\x02 \x01(\tR\achainId\"\x0f\n" + - "\rRelayResponse2[\n" + - "\x11RelayerApiService\x12F\n" + - "\x05Relay\x12\x1c.ibc.v2.relayer.RelayRequest\x1a\x1d.ibc.v2.relayer.RelayResponse\"\x00B6Z4github.com/cosmos/ibc/link/internal/types/v2/relayerb\x06proto3" - -var ( - file_relayer_proto_rawDescOnce sync.Once - file_relayer_proto_rawDescData []byte -) - -func file_relayer_proto_rawDescGZIP() []byte { - file_relayer_proto_rawDescOnce.Do(func() { - file_relayer_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_relayer_proto_rawDesc), len(file_relayer_proto_rawDesc))) - }) - return file_relayer_proto_rawDescData -} - -var file_relayer_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_relayer_proto_goTypes = []any{ - (*RelayRequest)(nil), // 0: ibc.v2.relayer.RelayRequest - (*RelayResponse)(nil), // 1: ibc.v2.relayer.RelayResponse -} -var file_relayer_proto_depIdxs = []int32{ - 0, // 0: ibc.v2.relayer.RelayerApiService.Relay:input_type -> ibc.v2.relayer.RelayRequest - 1, // 1: ibc.v2.relayer.RelayerApiService.Relay:output_type -> ibc.v2.relayer.RelayResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_relayer_proto_init() } -func file_relayer_proto_init() { - if File_relayer_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_relayer_proto_rawDesc), len(file_relayer_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_relayer_proto_goTypes, - DependencyIndexes: file_relayer_proto_depIdxs, - MessageInfos: file_relayer_proto_msgTypes, - }.Build() - File_relayer_proto = out.File - file_relayer_proto_goTypes = nil - file_relayer_proto_depIdxs = nil -} diff --git a/link/keyfile/keyfile.go b/link/keyfile/keyfile.go new file mode 100644 index 000000000..d58112b55 --- /dev/null +++ b/link/keyfile/keyfile.go @@ -0,0 +1,83 @@ +// Package keyfile owns the on-disk format for local signer credentials. +package keyfile + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// Type identifies the signing algorithm encoded in a local key file. +type Type string + +const ( + // EDDSA identifies an Ed25519 private key. + EDDSA Type = "eddsa" + // ECDSA identifies a secp256k1 private key. + ECDSA Type = "ecdsa" +) + +type credential struct { + Type Type `json:"type"` + PrivateKey string `json:"privateKeyBase64"` +} + +// Store writes a new local signer file with owner-only permissions. +func Store(path string, keyType Type, privateKey []byte) error { + if _, err := ParseType(string(keyType)); err != nil { + return err + } + data, err := json.Marshal(credential{ + Type: keyType, + PrivateKey: base64.StdEncoding.EncodeToString(privateKey), + }) + if err != nil { + return fmt.Errorf("encode local signer: %w", err) + } + if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o700); mkdirErr != nil { + return fmt.Errorf("create local signer directory: %w", mkdirErr) + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return errors.Join(err, file.Close()) + } + return file.Close() +} + +// ParseType validates a local signer key type. +func ParseType(raw string) (Type, error) { + switch Type(raw) { + case EDDSA, ECDSA: + return Type(raw), nil + default: + return "", fmt.Errorf("invalid key type: %s", raw) + } +} + +// Load reads one explicitly named local signer file. +func Load(path string) (Type, []byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", nil, err + } + var stored credential + if unmarshalErr := json.Unmarshal(data, &stored); unmarshalErr != nil { + return "", nil, unmarshalErr + } + keyType, err := ParseType(string(stored.Type)) + if err != nil { + return "", nil, err + } + privateKey, err := base64.StdEncoding.DecodeString(stored.PrivateKey) + if err != nil { + return "", nil, err + } + return keyType, privateKey, nil +} diff --git a/link/keyfile/keyfile_test.go b/link/keyfile/keyfile_test.go new file mode 100644 index 000000000..367e51e7a --- /dev/null +++ b/link/keyfile/keyfile_test.go @@ -0,0 +1,65 @@ +package keyfile_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/ibc/link/keyfile" +) + +func TestSignerFileContract(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "keys") + path := filepath.Join(dir, "signer.json") + require.NoError(t, keyfile.Store(path, keyfile.ECDSA, []byte{1, 2, 3})) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, `{"type":"ecdsa","privateKeyBase64":"AQID"}`, string(data)) + + dirInfo, err := os.Stat(dir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm()) + + fileInfo, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm()) + + keyType, privateKey, err := keyfile.Load(path) + require.NoError(t, err) + require.Equal(t, keyfile.ECDSA, keyType) + require.Equal(t, []byte{1, 2, 3}, privateKey) + + require.Error(t, keyfile.Store(path, keyfile.ECDSA, []byte{4, 5, 6})) +} + +func TestParseTypeRejectsUnknownType(t *testing.T) { + t.Parallel() + + _, err := keyfile.ParseType("rsa") + require.EqualError(t, err, "invalid key type: rsa") +} + +func TestLoadRejectsMalformedCredential(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "invalid JSON": `{`, + "invalid type": `{"type":"rsa","privateKeyBase64":"AQID"}`, + "invalid base64": `{"type":"ecdsa","privateKeyBase64":"%%%"}`, + } + for name, credential := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "signer.json") + require.NoError(t, os.WriteFile(path, []byte(credential), 0o600)) + _, _, err := keyfile.Load(path) + require.Error(t, err) + }) + } +} diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml index 757a2f1ba..c8ec8c659 100644 --- a/proto/buf.gen.yaml +++ b/proto/buf.gen.yaml @@ -1,9 +1,11 @@ version: v2 plugins: - remote: buf.build/protocolbuffers/go:v1.36.11 + revision: 1 out: ../link opt: module=github.com/cosmos/ibc/link - remote: buf.build/connectrpc/go:v1.20.0 + revision: 1 out: ../link opt: module=github.com/cosmos/ibc/link,package_suffix diff --git a/proto/link/attestor.proto b/proto/link/attestor.proto index 5f6b06925..8e65a0312 100644 --- a/proto/link/attestor.proto +++ b/proto/link/attestor.proto @@ -3,15 +3,89 @@ syntax = "proto3"; package ibc.v2.attestor; // todo: move to `github.com/cosmos/ibc/types/v2/attestor` -option go_package = "github.com/cosmos/ibc/link/internal/types/v2/attestor"; +option go_package = "github.com/cosmos/ibc/link/api/v2/attestor"; // Service definition for retrieving attestations. service AttestationService { + // Retrieves an attestation for a state at a given height. + rpc StateAttestation(StateAttestationRequest) + returns (StateAttestationResponse); + + // Retrieves an attestation for a set of packets. + rpc PacketAttestation(PacketAttestationRequest) + returns (PacketAttestationResponse); + // Returns the latest height the attestor will generate attestations for. rpc LatestAttestableHeight(LatestAttestableHeightRequest) returns (LatestAttestableHeightResponse); } +// Proof type for packet attestation. +enum ProofType { + // Packet commitment present in the attested chain's state (the packet's source). + PROOF_TYPE_PACKET_COMMITMENT = 0; + + // Acknowledgement present in the attested chain's state (the packet's destination). + PROOF_TYPE_PACKET_ACKNOWLEDGEMENT = 1; + + // Packet receipt absent from the attested chain's state (the packet's destination). + PROOF_TYPE_PACKET_RECEIPT_ABSENCE = 2; +} + +// Request message for getting an attestation for a state at a given height. +message StateAttestationRequest { + // Identifies which attestor to route the request to. + string attestor = 1; + + // The height to attest to. + uint64 height = 2; +} + +// Response message for getting an attestation for a state at a given height. +message StateAttestationResponse { + // The attestation. + Attestation attestation = 1; +} + +// Request message for getting an attestation for a set of packets. +// +// This request includes a height because packet attestations should be produced +// for a finalized, pinned chain height. +message PacketAttestationRequest { + // Identifies which attestor to route the request to. + string attestor = 1; + + // The packets to attest to. Each element is an independently ABI-encoded packet. + repeated bytes packets = 2; + + // The height to attest to the packets at. + uint64 height = 3; + + // The proof type to attest. + ProofType proof_type = 4; +} + +// Response message for getting an attestation for a set of packets. +message PacketAttestationResponse { + // The attestation. + Attestation attestation = 1; +} + message LatestAttestableHeightRequest { string attestor = 1; } -message LatestAttestableHeightResponse { uint64 height = 1; } \ No newline at end of file +message LatestAttestableHeightResponse { uint64 height = 1; } + +// Attestation is a single attestation from a given block height for the requested data. +message Attestation { + // The height of the attestation. + uint64 height = 1; + + // The timestamp of the block. Absent for packet attestations. + optional uint64 timestamp = 2; + + // The attested data. + bytes attested_data = 3; + + // The attestation signature. + bytes signature = 4; +} diff --git a/proto/link/relayer.proto b/proto/link/relayer.proto index 1affc1a31..a5a3f076d 100644 --- a/proto/link/relayer.proto +++ b/proto/link/relayer.proto @@ -2,13 +2,16 @@ syntax = "proto3"; package ibc.v2.relayer; -// todo: move to `github.com/cosmos/ibc/types/v2/relayer` -option go_package = "github.com/cosmos/ibc/link/internal/types/v2/relayer"; +option go_package = "github.com/cosmos/ibc/link/api/v2/relayer"; service RelayerApiService { // Relay will track the status of a transfer, and submit the transactions // required to complete the transfer via the bridging protocol. rpc Relay(RelayRequest) returns (RelayResponse) {} + + // Status returns per-packet transfer status for a transaction previously + // submitted via Relay. + rpc Status(StatusRequest) returns (StatusResponse) {} } message RelayRequest { @@ -17,3 +20,32 @@ message RelayRequest { } message RelayResponse {} + +message StatusRequest { + string tx_hash = 1; + string chain_id = 2; +} + +message StatusResponse { repeated PacketStatus packet_statuses = 1; } + +enum TransferState { + TRANSFER_STATE_UNKNOWN = 0; + TRANSFER_STATE_PENDING = 1; + TRANSFER_STATE_COMPLETE = 2; + TRANSFER_STATE_FAILED = 3; +} + +message TransactionInfo { + string tx_hash = 1; + string chain_id = 2; +} + +message PacketStatus { + TransferState state = 1; + uint64 sequence_number = 2; + string source_client_id = 3; + TransactionInfo send_tx = 4; + TransactionInfo recv_tx = 5; + TransactionInfo ack_tx = 6; + TransactionInfo timeout_tx = 7; +}