diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be09980..b56b99b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,34 @@ jobs: ! git ls-files | grep -E '(^|/)(private-spool|keystore)(/|$)' uv run python scripts/check_publishable_tree.py uv run pytest tests/unit/test_publication.py -q + go-runtime: + runs-on: ubuntu-latest + env: + XIR_LIVE_WRITES_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache-dependency-path: go-runtime/go.sum + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Build pinned real protocol fixtures + run: scripts/prepare_go_e2e.sh "$RUNNER_TEMP/go-e2e-protocols" + - run: go vet ./... + working-directory: go-runtime + - run: go test -count=1 ./... + working-directory: go-runtime + env: + XIR_REQUIRE_E2E: "1" + - name: Require real Anvil and recovery tests (no skips) + run: scripts/run_go_e2e.sh "$RUNNER_TEMP/go-e2e-results.json" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: go-e2e-results + path: ${{ runner.temp }}/go-e2e-results.json diff --git a/.gitignore b/.gitignore index aebf920..7f41e62 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,8 @@ data/blobs/ data/releases/ results/ artifacts/ +# The Go runtime's Forge artifact loader is source, not evidence output. +!go-runtime/internal/artifacts/ release/ releases/ diff --git a/CLAIM_BOUNDARIES.md b/CLAIM_BOUNDARIES.md index d64c13e..d98efe3 100644 --- a/CLAIM_BOUNDARIES.md +++ b/CLAIM_BOUNDARIES.md @@ -55,6 +55,22 @@ XIR-minus-baseline overhead requires matched routes, inputs, environments, and eligible samples from both arms. Planning a paired experiment does not establish that its measurements were collected. +## Go runtime + +`go-runtime/` is an independent implementation of the execution layer. Its +claims are limited to byte compatibility with the Solidity contracts and the +Python runtime, verified by the generated parity vectors, and to the execution +behaviour its integration tests exercise on disposable local chains. + +A Go run is not a campaign result: it does not reproduce the review gate, phase +authority, validator-volume attestation, resource monitor, or preflight digests, +and it produces no published measurement. The integration tests use anvil with +the frozen chain ids and the same contracts because the five-chain Besu QBFT +topology does not fit on a development host; they are fixtures, not the +environment of record. The Go runtime has no mainnet path and no public-testnet +write path, and its self-hosted Hyperlane validator/relayer and LayerZero +DVN/Executor roles are research roles, not managed third-party services. + ## Trust, costs, and reporting The paper's theorem is conditional on sound protocol deliveries, correct XIR diff --git a/README.md b/README.md index 3bab817..f6b241b 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ Adapters carry this record across hops so that the destination can check the message and its verification history before execution. This repository contains the XIR research implementation and experiment tools: -Solidity contracts, Python orchestration, Bash deployment scripts, versioned -configurations, and selected verification artifacts. The implemented EVM -protocol integrations are **Hyperlane and LayerZero V2**. The accompanying -observational dataset covers six protocols. +Solidity contracts, a Python orchestration runtime, an independent Go runtime +for the execution layer, Bash deployment scripts, versioned configurations, and +selected verification artifacts. The implemented EVM protocol integrations are +**Hyperlane and LayerZero V2**. The accompanying observational dataset covers +six protocols. ## How XIR works @@ -52,6 +53,7 @@ protocol for both connections. | [XIR Registry](contracts/src/XIRRegistry.sol) | Store verification profiles, approved components, and source authorization configuration | | [Hyperlane Adapter](contracts/src/HyperlaneAdapter.sol) and [LayerZero Adapter](contracts/src/LayerZeroAdapter.sol) | Connect native protocol callbacks and sends to the common representation | | [Python runtime](src/xir_lab/) | Plan and execute bounded experiments, collect evidence, reconcile outcomes, and build reports | +| [Go runtime](go-runtime/) | Independently implement the execution layer: byte-compatible encoding and signing, durable dispatch and confirmation, and restart recovery. It consumes frozen plans and deployment documents; planning, governance gates, analysis, and publication stay Python-side ([notes](docs/go-runtime.md)) | The controlled experiments use Besu QBFT chains. Hyperlane's validator and relayer are upstream Rust components; the local LayerZero deployment combines diff --git a/configs/go-runtime/local-hl-example.json b/configs/go-runtime/local-hl-example.json new file mode 100644 index 0000000..0d4c218 --- /dev/null +++ b/configs/go-runtime/local-hl-example.json @@ -0,0 +1,31 @@ +{ + "schema_version": "xir-go-runtime-config-v1", + "chains": [ + {"role": "a", "chain_id": 3133701, "rpc_url": "http://127.0.0.1:18545", "hyperlane_domain": 3133701, "layerzero_eid": 49001}, + {"role": "b", "chain_id": 3133702, "rpc_url": "http://127.0.0.1:28545", "hyperlane_domain": 3133702, "layerzero_eid": 49002}, + {"role": "c", "chain_id": 3133703, "rpc_url": "http://127.0.0.1:38545", "hyperlane_domain": 3133703, "layerzero_eid": 49003} + ], + "attempts": [ + {"attempt_id": "go-runtime-example-000001", "phase": "smoke", "route": "HL", "route_sequence": 0, "switch_count": 1} + ], + "payload_schedule": {"minimum_bytes": 32, "size_bucket_count": 4, "size_step_bytes": 32}, + "fixed_seed": "xir-go-runtime-example", + "policy_label": "XIR_NATIVE_MULTIHOP_POLICY_V1", + "deployment": "../xir-lab-runtime/deployment.json", + "artifacts_root": "contracts/out", + "protocol_root": "protocol-projects", + "state_path": "../xir-lab-runtime/runner-state.sqlite", + "raw_root": "../xir-lab-runtime/raw", + "timeout_seconds": 600, + "poll_millis": 500, + "gas_limit": 6000000, + "embedded_agents": true, + "finality": {"mode": "rpc-finalized", "timeout_seconds": 120}, + "key_environment": { + "runner": "XIR_RUNNER_KEY", + "root_signer": "XIR_ROOT_SIGNER_KEY", + "layerzero_worker": "XIR_LZ_WORKER_KEY", + "hyperlane_validator": "XIR_HL_VALIDATOR_KEY", + "hyperlane_relayer": "XIR_HL_RELAYER_KEY" + } +} diff --git a/docs/go-runtime.md b/docs/go-runtime.md new file mode 100644 index 0000000..d347bd0 --- /dev/null +++ b/docs/go-runtime.md @@ -0,0 +1,97 @@ +# Go runtime + +`go-runtime/` holds an independent implementation of the XIR execution layer. +It was added so the delivery, confirmation, and recovery paths can be reviewed, +tested, and operated without the Python runtime's analysis and publication +machinery, while staying byte compatible with the Solidity contracts and with +the frozen campaign artefacts. + +## What was ported + +| Python reference | Go implementation | +| --- | --- | +| `src/xir_lab/native/xir_trace.py` | `internal/xir` (typed ids, record/context/receipt hashing, `rid`/`mid`, prefix chain, bundle commitment, tuples) | +| `src/xir_lab/native/multihop_deployer.py` identity helpers | `internal/xir` (`ProfileHash`, `GatewayTypedID`, `AdapterKey`) | +| `src/xir_lab/native/multihop_runner.py` coordinator | `internal/runner` (attempt loop, carriers, transitions, delivery, effect check) | +| `src/xir_lab/native/layerzero.py` | `internal/layerzero` (packet codec, options, DVN instruction, stage encoders) | +| `src/xir_lab/native/layerzero_worker.py` | `internal/layerzero` `Plan` + `internal/runner` LayerZero carrier | +| `src/xir_lab/native/runner.py` and `multihop_runner.py` state | `internal/state` (SQLite ledger with the same tables) | +| `src/xir_lab/native/root_signer.py` | `internal/runner` root signing (`verifyRootCreation`) and `internal/evm` signer | +| `src/xir_lab/native/multihop_deployer.py` deployment | `internal/deploy` | + +The Hyperlane delivery role has no Python counterpart: the native campaign used +the upstream Rust validator and relayer binaries, so `internal/hyperlane` and the +embedded relayer in `internal/runner` are new code. They follow the pinned +Hyperlane sources (`toolchain/native-protocol-stack.lock.json` commit +`5857ead81a8783d168d48d370be72de88d5fb230`) and are accepted by the real +`StaticMessageIdMultisigIsm` deployed in the integration test, which is the +binding check for the checkpoint digest, the metadata layout, and the validator +signature. + +## What was deliberately not ported + +The campaign governance chain stays Python-side: the review gate, phase +authority, validator-volume attestation, resource monitor, preflight digests, +plan materialisation, analysis, and publication. A Go run therefore produces +execution evidence, never a campaign result. + +## Parity + +`go-runtime/testdata/vectors.json` is generated from the Python modules by +`scripts/generate_go_parity_vectors.py` and pins: + +- typed-id encodings and hashes; +- record, context, root id, message id, root prefix, transition hashes; +- receipt hashes, prefix chains, bundle start/step commitments; +- multihop profile hashes, gateway typed ids, adapter keys; +- application payloads and the `NativeMultihopPayload` encoding; +- the full `XIRTypes.Envelope` ABI encoding and the EIP-191 root signature; +- the LayerZero packet fields, DVN instruction (call data, hash, signature) and + the three stage calldatas; +- the Hyperlane evidence hash. + +`internal/xir`, `internal/layerzero`, and `internal/hyperlane` assert those bytes +directly, and the runner's on-chain calls are accepted by the same contracts the +Python runner used, which checks the encoding a second time from the other side. + +## Verification + +```console +$ cd go-runtime +$ go vet ./... +$ go test ./... +``` + +Unit and parity tests run anywhere. Integration tests deploy the pinned real +Hyperlane and LayerZero contracts onto disposable local Anvil chains. Build the +fixtures from the protocol lock (Git, jq, Foundry, Node 22 and Corepack required): + +```console +$ scripts/prepare_go_e2e.sh /tmp/xir-go-e2e-protocols +$ scripts/run_go_e2e.sh /tmp/xir-go-e2e-results.json +``` + +The preparation script fetches the two locked protocol commits, installs their +Soldeer/Yarn dependencies and builds Solidity artifacts. It does not build Rust +validators or send transactions to remote networks. The test script forces +`XIR_REQUIRE_E2E=1`, disables test caching, retains Go JSON output and rejects any +skip, failure, or missing required recovery test. CI runs both scripts; a skipped +integration test cannot count as success. Plain `go test ./...` still permits +missing-fixture skips for developers; use the script for release verification. + +The integration tests assert the destination `NativeMultihopEffectApplied` +event, the receiver's `deliveryCount`, and that each carrier dispatched exactly +once. `TestAnvilRestartResumesWithoutDuplicateDispatch` interrupts an attempt +after its first dispatch with an injected fault, restarts the runtime over the +same ledger, and asserts that the attempt completes while the dispatch count +stays at one. + +## Environment boundary + +The published measurements come from the frozen five-chain Besu QBFT topology, +which requires at least eight logical CPUs and 20 GB of memory +(`configs/local/topology-multihop-remote-v1.json`). The integration tests use +anvil with the same chain ids and the same contracts because that topology does +not fit on a development host. Anvil chains are test fixtures: they produce no +campaign evidence, and a Go run is never a substitute for a frozen campaign +result. diff --git a/go-runtime/README.md b/go-runtime/README.md new file mode 100644 index 0000000..8dc14af --- /dev/null +++ b/go-runtime/README.md @@ -0,0 +1,89 @@ +# XIR Go runtime + +`xirlab-go` is an independent implementation of the XIR execution layer. It +creates XIR roots, dispatches every hop through the Hyperlane or LayerZero +carrier adapters, confirms the evidence each adapter recorded, records carrier +switches, delivers the envelope to the destination gateway, and survives a +mid-flight process restart without duplicating a transaction. + +The Python runtime in `src/xir_lab/` remains the authority for frozen plans, +preflight gates, analysis, and publication. The Go runtime consumes those frozen +inputs (a deployment document and a list of attempts) and writes the same +durable rows, so the Python analysis tooling can still read a Go-produced run. + +## Layout + +| Package | Contents | +| --- | --- | +| `internal/xir` | Wire encoding: typed ids, record/context/receipt hashing, `rid`/`mid`/prefix chain, bundle commitments, profile hashes, application payloads, ABI tuple shapes | +| `internal/abiutil` | ABI document parsing, calldata packing, log decoding | +| `internal/state` | Durable SQLite ledger: actions, attempts, stages, stage history, events, errors | +| `internal/evm` | JSON-RPC client, signer, transactor with intent→signed→submitted→receipt durability, finality rule | +| `internal/artifacts` | Forge artifact loading (`out/.sol/.json`) | +| `internal/deploy` | Protocol-stack and XIR application deployment, registry and adapter configuration | +| `internal/layerzero` | PacketV1Codec, DVN instruction, DVN/ReceiveUln302/Executor submission plan | +| `internal/hyperlane` | Message format, checkpoint digest, ISM metadata, validator signing, mailbox `process` plan | +| `internal/runner` | Multihop orchestration, carriers, delivery, destination effect checks | +| `internal/lab` | Disposable anvil chains for integration tests | +| `cmd/xirlab-go` | Command line entry point | + +## Parity + +Every derivation is byte exact with the Solidity contracts and with the Python +runtime. The parity suite is generated from the Python modules: + +```console +$ uv run python scripts/generate_go_parity_vectors.py --output go-runtime/testdata/vectors.json +$ cd go-runtime && go test ./... +``` + +`go-runtime/testdata/vectors.json` pins typed-id encodings, record/context/root +hashes, message ids, prefix chains, bundle commitments, profile hashes, gateway +ids, adapter keys, application payloads, the full envelope ABI encoding, the +EIP-191 root signature, the LayerZero packet and DVN instruction, and the +Hyperlane evidence hash. `internal/xir` and `internal/layerzero` compare against +those bytes; a mismatch is a defect, never an approximation. + +## Running + +The runtime reads one JSON configuration document +(`schemas/xir-go-runtime-config-v1.schema.json`). Private keys never appear in +the document: each entry of `key_environment` names an environment variable that +holds one signing key. + +```console +$ export XIR_RUNNER_KEY=0x... # runner: createRecord, recorder.record, deliver +$ export XIR_ROOT_SIGNER_KEY=0x... # XIR root certificate signer +$ export XIR_LZ_WORKER_KEY=0x... # LayerZero DVN signer and Executor submitter +$ export XIR_HL_VALIDATOR_KEY=0x... # Hyperlane validator +$ export XIR_HL_RELAYER_KEY=0x... # Hyperlane process() submitter +$ cd go-runtime && go run ./cmd/xirlab-go run --config ../configs/go-runtime/local-hl.json +``` + +`--config` names the chains, the deployment document, the attempts, and the +private state paths. `state_path` and `raw_root` must live outside the +repository: they hold the durable ledger and the raw signed transactions. + +Set `embedded_agents` to `false` when an external LayerZero DVN/Executor or +Hyperlane validator/relayer should perform the delivery; the runtime then only +dispatches and waits for the destination adapter to report the evidence. + +`finality.mode` decides when a created root may be signed. `rpc-finalized` (the +default) requires the creation block to reach the finalized height the node +reports, and falls back to the private-QBFT rule when the node does not +implement the tag, which is what the frozen Besu lab does. `confirmations` +requires N successor blocks and a canonical creation block; it exists because +development chains such as anvil pin the finalized tag at genesis. `none` +accepts the mined receipt and is recorded in the ledger as test-only. + +## What this runtime is not + +- It is not a production bridge, wallet, or relayer service. +- It does not reproduce the campaign governance gates (review gate, phase + authority, validator-volume attestation, resource monitor, preflight digests); + those remain Python-side and a Go run is not a campaign result. +- It has no mainnet path and no public-testnet write path. +- Its integration tests run on disposable anvil chains with the same chain ids + and one-second block period as the frozen five-chain Besu topology, because + that topology needs more CPU and memory than a development host provides. The + frozen lab remains the environment of record for published measurements. diff --git a/go-runtime/cmd/xirlab-go/main.go b/go-runtime/cmd/xirlab-go/main.go new file mode 100644 index 0000000..e00b7e8 --- /dev/null +++ b/go-runtime/cmd/xirlab-go/main.go @@ -0,0 +1,249 @@ +// Command xirlab-go runs the XIR execution layer: it can execute frozen +// multihop attempts against a deployed application, deploy that application to +// a local topology, and check the parity vectors the runtime is built against. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/lysrain21/XIR/go-runtime/internal/runner" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// version is stamped at build time with -ldflags "-X main.version=...". +var version = "0.1.0-dev" + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "xirlab-go: %v\n", err) + os.Exit(1) + } +} + +func run(arguments []string) error { + if len(arguments) == 0 { + usage() + return errors.New("no subcommand given") + } + switch arguments[0] { + case "version": + fmt.Println(version) + return nil + case "run": + return runCommand(arguments[1:]) + case "help", "-h", "--help": + usage() + return nil + default: + usage() + return fmt.Errorf("unknown subcommand %q", arguments[0]) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `usage: xirlab-go + +commands: + run --config execute the attempts of a frozen runtime config + version print the build version + help print this message + +The runtime never reads a private key from a file: the config names environment +variables that hold each signing key. +`) +} + +// fileConfig is the on-disk runtime configuration. +type fileConfig struct { + SchemaVersion string `json:"schema_version"` + Chains []struct { + Role string `json:"role"` + ChainID uint64 `json:"chain_id"` + RPCURL string `json:"rpc_url"` + HyperlaneDomain uint32 `json:"hyperlane_domain"` + LayerZeroEID uint32 `json:"layerzero_eid"` + } `json:"chains"` + Attempts []struct { + AttemptID string `json:"attempt_id"` + Phase string `json:"phase"` + Route string `json:"route"` + RouteSequence uint64 `json:"route_sequence"` + SwitchCount int `json:"switch_count"` + PayloadSHA256 string `json:"payload_sha256"` + } `json:"attempts"` + PayloadSchedule struct { + MinimumBytes uint64 `json:"minimum_bytes"` + SizeBucketCount uint64 `json:"size_bucket_count"` + SizeStepBytes uint64 `json:"size_step_bytes"` + } `json:"payload_schedule"` + FixedSeed string `json:"fixed_seed"` + PolicyLabel string `json:"policy_label"` + Deployment string `json:"deployment"` + ArtifactsRoot string `json:"artifacts_root"` + ProtocolRoot string `json:"protocol_root"` + StatePath string `json:"state_path"` + RawRoot string `json:"raw_root"` + TimeoutSeconds int `json:"timeout_seconds"` + PollMillis int `json:"poll_millis"` + Concurrency int `json:"concurrency"` + GasLimit uint64 `json:"gas_limit"` + StopFile string `json:"stop_file"` + EmbeddedAgents bool `json:"embedded_agents"` + FaultAfterStage string `json:"fault_after_stage"` + Finality struct { + Mode string `json:"mode"` + Confirmations uint64 `json:"confirmations"` + TimeoutSeconds int `json:"timeout_seconds"` + } `json:"finality"` + KeyEnvironment struct { + Runner string `json:"runner"` + RootSigner string `json:"root_signer"` + LayerZeroWorker string `json:"layerzero_worker"` + HyperlaneValidator string `json:"hyperlane_validator"` + HyperlaneRelayer string `json:"hyperlane_relayer"` + } `json:"key_environment"` +} + +func runCommand(arguments []string) error { + flags := flag.NewFlagSet("run", flag.ContinueOnError) + configPath := flags.String("config", "", "path to the runtime configuration file") + if err := flags.Parse(arguments); err != nil { + return err + } + if *configPath == "" { + return errors.New("run needs --config") + } + payload, err := os.ReadFile(*configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + var document fileConfig + if err := json.Unmarshal(payload, &document); err != nil { + return fmt.Errorf("decode config: %w", err) + } + config, err := document.resolve() + if err != nil { + return err + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + executor, err := runner.New(config) + if err != nil { + return err + } + defer func() { + if closeErr := executor.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "xirlab-go: close runner: %v\n", closeErr) + } + }() + started := time.Now() + summary, err := executor.Run(ctx) + if encodeErr := printSummary(summary); encodeErr != nil { + return encodeErr + } + if err != nil { + return fmt.Errorf("run failed after %s: %w", time.Since(started).Round(time.Millisecond), err) + } + fmt.Fprintf(os.Stderr, "xirlab-go: %d/%d attempts succeeded in %s\n", + summary.Succeeded, summary.Attempts, time.Since(started).Round(time.Millisecond)) + if len(summary.Failures) > 0 { + return fmt.Errorf("%d attempts failed", len(summary.Failures)) + } + return nil +} + +func (d fileConfig) resolve() (runner.Config, error) { + if d.SchemaVersion != "xir-go-runtime-config-v1" { + return runner.Config{}, fmt.Errorf("unexpected config schema %q", d.SchemaVersion) + } + config := runner.Config{ + DeploymentPath: d.Deployment, + ArtifactsRoot: d.ArtifactsRoot, + ProtocolRoot: d.ProtocolRoot, + StatePath: d.StatePath, + RawRoot: d.RawRoot, + FixedSeed: d.FixedSeed, + PolicyLabel: d.PolicyLabel, + Concurrency: d.Concurrency, + StopFile: d.StopFile, + EmbeddedAgents: d.EmbeddedAgents, + FaultAfterStage: d.FaultAfterStage, + GasLimit: d.GasLimit, + Finality: runner.Finality{ + Mode: d.Finality.Mode, + Confirmations: d.Finality.Confirmations, + Timeout: time.Duration(d.Finality.TimeoutSeconds) * time.Second, + }, + PayloadSchedule: xir.PayloadSchedule{ + MinimumBytes: d.PayloadSchedule.MinimumBytes, + SizeBucketCount: d.PayloadSchedule.SizeBucketCount, + SizeStepBytes: d.PayloadSchedule.SizeStepBytes, + }, + } + if d.TimeoutSeconds > 0 { + config.Timeout = time.Duration(d.TimeoutSeconds) * time.Second + } + if d.PollMillis > 0 { + config.PollInterval = time.Duration(d.PollMillis) * time.Millisecond + } + for _, chain := range d.Chains { + config.Chains = append(config.Chains, runner.ChainConfig{ + Role: chain.Role, + ChainID: chain.ChainID, + RPCURL: chain.RPCURL, + HyperlaneDomain: chain.HyperlaneDomain, + LayerZeroEID: chain.LayerZeroEID, + }) + } + for _, attempt := range d.Attempts { + config.Attempts = append(config.Attempts, runner.Attempt{ + AttemptID: attempt.AttemptID, + Phase: attempt.Phase, + Route: attempt.Route, + RouteSequence: attempt.RouteSequence, + SwitchCount: attempt.SwitchCount, + PayloadSHA256: attempt.PayloadSHA256, + }) + } + keys := []struct { + name string + into *string + }{ + {d.KeyEnvironment.Runner, &config.Keys.Runner}, + {d.KeyEnvironment.RootSigner, &config.Keys.RootSigner}, + {d.KeyEnvironment.LayerZeroWorker, &config.Keys.LayerZeroWorker}, + {d.KeyEnvironment.HyperlaneValidator, &config.Keys.HyperlaneValidator}, + {d.KeyEnvironment.HyperlaneRelayer, &config.Keys.HyperlaneRelayer}, + } + for _, key := range keys { + if key.name == "" { + continue + } + value, ok := os.LookupEnv(key.name) + if !ok || value == "" { + return runner.Config{}, fmt.Errorf("environment variable %s is not set", key.name) + } + *key.into = value + } + if config.Keys.Runner == "" || config.Keys.RootSigner == "" { + return runner.Config{}, errors.New("config must name the runner and root signer key environment variables") + } + return config, nil +} + +func printSummary(summary runner.Summary) error { + encoded, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Errorf("encode summary: %w", err) + } + _, err = os.Stdout.Write(append(encoded, '\n')) + return err +} diff --git a/go-runtime/go.mod b/go-runtime/go.mod new file mode 100644 index 0000000..edd82ee --- /dev/null +++ b/go-runtime/go.mod @@ -0,0 +1,44 @@ +module github.com/lysrain21/XIR/go-runtime + +go 1.23 + +require ( + github.com/ethereum/go-ethereum v1.14.11 + golang.org/x/crypto v0.22.0 + golang.org/x/sys v0.22.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/consensys/bavard v0.1.13 // indirect + github.com/consensys/gnark-crypto v0.12.1 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect + github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ethereum/c-kzg-4844 v1.0.0 // indirect + github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mmcloughlin/addchain v0.4.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.13 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + golang.org/x/sync v0.7.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + rsc.io/tmplfunc v0.0.3 // indirect +) diff --git a/go-runtime/go.sum b/go-runtime/go.sum new file mode 100644 index 0000000..3ec1e1c --- /dev/null +++ b/go-runtime/go.sum @@ -0,0 +1,222 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= +github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= +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.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +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.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +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.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA= +github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +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= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= +github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= +github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= +github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I= +github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs= +github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI= +github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA= +github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= +github.com/ethereum/go-ethereum v1.14.11 h1:8nFDCUUE67rPc6AKxFj7JKaOa2W/W1Rse3oS6LvvxEY= +github.com/ethereum/go-ethereum v1.14.11/go.mod h1:+l/fr42Mma+xBnhefL/+z11/hcmJ2egl+ScIVPjhc7E= +github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A= +github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +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-ole/go-ole v1.2.5/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.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +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.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +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.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +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.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= +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.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs= +github.com/holiman/uint256 v1.3.1/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/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.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= +github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= +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.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/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/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/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= +github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg= +github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +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.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/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= +github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk= +github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +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= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= +rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/go-runtime/internal/abiutil/abiutil.go b/go-runtime/internal/abiutil/abiutil.go new file mode 100644 index 0000000..cdc993b --- /dev/null +++ b/go-runtime/internal/abiutil/abiutil.go @@ -0,0 +1,249 @@ +// Package abiutil wraps the go-ethereum ABI codec for the XIR runtime. +// +// Every XIR call is packed from a JSON ABI document: either a Forge artifact +// (loaded by internal/artifacts) or a fragment committed next to the code that +// uses it. Tuple arguments are passed as Go structs whose fields follow the ABI +// component order; the codec in go-ethereum accepts struct values for tuples +// only when the type was built from JSON components, which is why every type in +// this runtime originates from an ABI document rather than from a type string. +package abiutil + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Contract is one parsed ABI document. +type Contract struct { + name string + abi abi.ABI +} + +// New parses an ABI JSON document. +func New(document string) (*Contract, error) { + parsed, err := abi.JSON(strings.NewReader(document)) + if err != nil { + return nil, fmt.Errorf("abiutil: parse ABI: %w", err) + } + return &Contract{abi: parsed}, nil +} + +// Name returns the contract name recorded for evidence rows. +func (c *Contract) Name() string { return c.name } + +// SetName records the contract name for diagnostics and evidence rows. +func (c *Contract) SetName(name string) { c.name = name } + +// ABI exposes the parsed codec for callers that need it directly. +func (c *Contract) ABI() abi.ABI { return c.abi } + +// PackCall returns selector || encoded arguments for one function. +// +// go-ethereum's ABI.Pack already prefixes the four-byte selector, so the result +// is used verbatim; prefixing again would emit calldata that calls nothing. +func (c *Contract) PackCall(name string, args ...any) ([]byte, error) { + if _, ok := c.abi.Methods[name]; !ok { + return nil, fmt.Errorf("abiutil: %s has no method %q", c.describe(), name) + } + encoded, err := c.abi.Pack(name, args...) + if err != nil { + return nil, fmt.Errorf("abiutil: pack %s.%s: %w", c.describe(), name, err) + } + return encoded, nil +} + +// UnpackOutputs decodes the return data of one function into the given +// destinations, which must be pointers and must match the output count. +// +// The codec accepts one pointer to the single return value, or a pointer to a +// slice for a multi-value return, so the destinations are forwarded in the +// shape it expects rather than as a variadic slice. +func (c *Contract) UnpackOutputs(name string, data []byte, into ...any) error { + method, ok := c.abi.Methods[name] + if !ok { + return fmt.Errorf("abiutil: %s has no method %q", c.describe(), name) + } + if len(method.Outputs) == 0 { + return nil + } + if len(into) != len(method.Outputs) { + return fmt.Errorf( + "abiutil: %s.%s returns %d values but %d destinations were given", + c.describe(), name, len(method.Outputs), len(into), + ) + } + for index, item := range into { + value := reflect.ValueOf(item) + if value.Kind() != reflect.Pointer || value.IsNil() { + return fmt.Errorf( + "abiutil: %s.%s destination %d is not a non-nil pointer", + c.describe(), name, index, + ) + } + } + destination := into + if len(into) == 1 { + if err := c.abi.UnpackIntoInterface(into[0], name, data); err != nil { + return fmt.Errorf("abiutil: unpack %s.%s: %w", c.describe(), name, err) + } + return nil + } + if err := c.abi.UnpackIntoInterface(&destination, name, data); err != nil { + return fmt.Errorf("abiutil: unpack %s.%s: %w", c.describe(), name, err) + } + return nil +} + +// EventTopic returns the topic0 of one event. +func (c *Contract) EventTopic(name string) (common.Hash, error) { + event, ok := c.abi.Events[name] + if !ok { + return common.Hash{}, fmt.Errorf("abiutil: %s has no event %q", c.describe(), name) + } + return event.ID, nil +} + +// UnpackLog decodes one log into the named inputs of the event. +func (c *Contract) UnpackLog(name string, log *types.Log) (map[string]any, error) { + event, ok := c.abi.Events[name] + if !ok { + return nil, fmt.Errorf("abiutil: %s has no event %q", c.describe(), name) + } + values := map[string]any{} + if err := c.abi.UnpackIntoMap(values, name, log.Data); err != nil { + return nil, fmt.Errorf("abiutil: unpack %s.%s data: %w", c.describe(), name, err) + } + // Indexed arguments live in the topics. ParseTopicsIntoMap decodes value + // types and returns the topic hash itself for dynamic types, which is what + // the topic actually carries. + indexed := make(abi.Arguments, 0, len(event.Inputs)) + for _, input := range event.Inputs { + if input.Indexed { + indexed = append(indexed, input) + } + } + if len(indexed) > 0 { + if len(log.Topics) != len(indexed)+1 { + return nil, fmt.Errorf( + "abiutil: %s.%s log has %d topics for %d indexed arguments", + c.describe(), name, len(log.Topics), len(indexed), + ) + } + if err := abi.ParseTopicsIntoMap(values, indexed, log.Topics[1:]); err != nil { + return nil, fmt.Errorf("abiutil: unpack %s.%s topics: %w", c.describe(), name, err) + } + } + return values, nil +} + +// ArgumentFragment renders one input of a method back to ABI JSON components. +// The result can be passed to PackArgument. +func (c *Contract) ArgumentFragment(methodName string, index int) (string, error) { + method, ok := c.abi.Methods[methodName] + if !ok { + return "", fmt.Errorf("abiutil: %s has no method %q", c.describe(), methodName) + } + if index < 0 || index >= len(method.Inputs) { + return "", fmt.Errorf("abiutil: method %q has no argument %d", methodName, index) + } + encoded, err := json.Marshal(renderArgument(method.Inputs[index])) + if err != nil { + return "", fmt.Errorf("abiutil: render argument: %w", err) + } + return string(encoded), nil +} + +// PackArgument ABI-encodes one bare argument described by a JSON fragment. +func PackArgument(fragment string, value any) ([]byte, error) { + var argument abi.Argument + if err := json.Unmarshal([]byte(fragment), &argument); err != nil { + return nil, fmt.Errorf("abiutil: parse argument fragment: %w", err) + } + encoded, err := abi.Arguments{argument}.Pack(value) + if err != nil { + return nil, fmt.Errorf("abiutil: pack argument: %w", err) + } + return encoded, nil +} + +// PackEncode ABI-encodes several arguments the way Solidity's abi.encode does. +// fragments is a JSON array of ABI argument objects; values follow in order. +// +// The arguments are packed as a synthetic method's inputs because the codec +// accepts tuple-typed values only as method or event inputs; the synthetic +// selector is dropped so the result is exactly abi.encode(...). +func PackEncode(fragments string, values ...any) ([]byte, error) { + document := fmt.Sprintf( + `[{"type":"function","name":"encode","stateMutability":"pure","inputs":%s,"outputs":[]}]`, + fragments, + ) + contract, err := New(document) + if err != nil { + return nil, err + } + method, ok := contract.abi.Methods["encode"] + if !ok { + return nil, fmt.Errorf("abiutil: synthetic encode method is missing") + } + encoded, err := method.Inputs.Pack(values...) + if err != nil { + return nil, fmt.Errorf("abiutil: pack encode: %w", err) + } + return encoded, nil +} + +// DecodeHex decodes a 0x-prefixed hexadecimal string. +func DecodeHex(value string) ([]byte, error) { + raw, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return nil, fmt.Errorf("abiutil: decode %q: %w", value, err) + } + return raw, nil +} + +func (c *Contract) describe() string { + if c.name == "" { + return "contract" + } + return c.name +} + +func renderArgument(input abi.Argument) map[string]any { + rendered := map[string]any{ + "name": input.Name, + "type": input.Type.String(), + } + if components := renderComponents(input.Type); len(components) > 0 { + rendered["components"] = components + } + if input.Indexed { + rendered["indexed"] = true + } + return rendered +} + +func renderComponents(t abi.Type) []map[string]any { + if len(t.TupleElems) == 0 { + return nil + } + components := make([]map[string]any, 0, len(t.TupleElems)) + for index, element := range t.TupleElems { + name := "" + if index < len(t.TupleRawNames) { + name = t.TupleRawNames[index] + } + rendered := map[string]any{"name": name, "type": element.String()} + if nested := renderComponents(*element); len(nested) > 0 { + rendered["components"] = nested + } + components = append(components, rendered) + } + return components +} diff --git a/go-runtime/internal/abiutil/abiutil_test.go b/go-runtime/internal/abiutil/abiutil_test.go new file mode 100644 index 0000000..2c65017 --- /dev/null +++ b/go-runtime/internal/abiutil/abiutil_test.go @@ -0,0 +1,177 @@ +package abiutil + +import ( + "bytes" + "encoding/binary" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +const verifyFragment = `[{"type":"function","name":"verify","stateMutability":"view", + "inputs":[{"name":"profileHash","type":"bytes32"},{"name":"evidenceHash","type":"bytes32"}, + {"name":"transitionHash","type":"bytes32"}], + "outputs":[{"type":"bool"}]}]` + +func TestPackCallPrefixesSelectorExactlyOnce(t *testing.T) { + contract, err := New(verifyFragment) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + method, ok := contract.ABI().Methods["verify"] + if !ok { + t.Fatal("verify method is missing") + } + data, err := contract.PackCall("verify", [32]byte{1}, [32]byte{2}, [32]byte{3}) + if err != nil { + t.Fatalf("pack call: %v", err) + } + if !bytes.HasPrefix(data, method.ID[:]) { + t.Fatalf("calldata does not start with the selector: %x", data[:8]) + } + if bytes.HasPrefix(data[4:], method.ID[:]) { + t.Fatalf("calldata repeats the selector: %x", data[:12]) + } + if len(data) != 4+96 { + t.Fatalf("calldata is %d bytes, want 100", len(data)) + } +} + +func TestPackEncodeMatchesAbiEncodeLayout(t *testing.T) { + fragments := `[{"name":"domain","type":"uint32"},{"name":"sender","type":"bytes32"},{"name":"body","type":"bytes"}]` + encoded, err := PackEncode(fragments, uint32(7), [32]byte{8}, []byte{9, 9}) + if err != nil { + t.Fatalf("pack encode: %v", err) + } + // abi.encode(uint32,bytes32,bytes): three head words, then length and data. + if len(encoded) != 96+32+32 { + t.Fatalf("abi.encode output is %d bytes, want 160", len(encoded)) + } + if binary.BigEndian.Uint32(encoded[28:32]) != 7 { + t.Fatalf("first word does not carry the uint32 value: %x", encoded[:32]) + } + if encoded[32] != 8 || encoded[33] != 0 { + t.Fatalf("second word does not carry the bytes32 value: %x", encoded[32:64]) + } + if binary.BigEndian.Uint32(encoded[124:128]) != 2 { + t.Fatalf("bytes length word is %d, want 2", binary.BigEndian.Uint32(encoded[124:128])) + } +} + +func TestPackArgumentRejectsMalformedFragments(t *testing.T) { + if _, err := PackArgument(`{"name":"x","type":"uint257"}`, uint64(1)); err == nil { + t.Fatal("malformed fragment was accepted") + } +} + +func TestDecodeHexRejectsNonHex(t *testing.T) { + if _, err := DecodeHex("0xzz"); err == nil { + t.Fatal("non-hex input was accepted") + } + if decoded, err := DecodeHex("0x0a0b"); err != nil || len(decoded) != 2 || decoded[1] != 0x0b { + t.Fatalf("0x0a0b decoded to %x with error %v", decoded, err) + } +} + +const eventFragment = `[{"type":"event","name":"Routed","inputs":[ + {"name":"rid","type":"bytes32","indexed":true}, + {"name":"receiver","type":"address","indexed":true}, + {"name":"count","type":"uint64","indexed":false}, + {"name":"blob","type":"bytes","indexed":true}]}]` + +func TestUnpackLogDecodesIndexedAndDataArguments(t *testing.T) { + contract, err := New(eventFragment) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + topic, err := contract.EventTopic("Routed") + if err != nil { + t.Fatalf("event topic: %v", err) + } + var rid common.Hash + rid[31] = 9 + receiver := common.HexToAddress("0x00000000000000000000000000000000000000aa") + blobHash := common.HexToHash("0x11") + log := &types.Log{ + Topics: []common.Hash{topic, rid, common.BytesToHash(receiver.Bytes()), blobHash}, + Data: common.LeftPadBytes(big.NewInt(7).Bytes(), 32), + } + values, err := contract.UnpackLog("Routed", log) + if err != nil { + t.Fatalf("unpack log: %v", err) + } + if observed, ok := values["rid"].([32]byte); !ok || observed != [32]byte(rid) { + t.Fatalf("rid decoded to %#v", values["rid"]) + } + if observed, ok := values["receiver"].(common.Address); !ok || observed != receiver { + t.Fatalf("receiver decoded to %#v", values["receiver"]) + } + if observed, ok := values["count"].(uint64); !ok || observed != 7 { + t.Fatalf("count decoded to %#v", values["count"]) + } + if observed, ok := values["blob"].(common.Hash); !ok || observed != blobHash { + t.Fatalf("dynamic indexed blob decoded to %#v", values["blob"]) + } +} + +func TestUnpackLogRejectsTopicCountMismatch(t *testing.T) { + contract, err := New(eventFragment) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + topic, err := contract.EventTopic("Routed") + if err != nil { + t.Fatalf("event topic: %v", err) + } + log := &types.Log{Topics: []common.Hash{topic}, Data: make([]byte, 32)} + if _, err := contract.UnpackLog("Routed", log); err == nil { + t.Fatal("a log without its indexed topics was accepted") + } +} + +const createFragment = `[{"type":"function","name":"create","stateMutability":"nonpayable", + "inputs":[{"name":"destination","type":"address"}], + "outputs":[{"name":"rid","type":"bytes32"},{"name":"mid","type":"bytes32"}]}, + {"type":"function","name":"selfId","stateMutability":"view","inputs":[], + "outputs":[{"name":"id","type":"address"}]}]` + +func TestUnpackOutputsAcceptsSingleAndMultipleDestinations(t *testing.T) { + contract, err := New(createFragment) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + var rid, mid common.Hash + rid[31] = 3 + mid[31] = 4 + data := append(rid.Bytes(), mid.Bytes()...) + if err := contract.UnpackOutputs("create", data, &rid, &mid); err != nil { + t.Fatalf("unpack two destinations: %v", err) + } + if rid[31] != 3 || mid[31] != 4 { + t.Fatalf("decoded %x %x", rid, mid) + } + var id common.Address + if err := contract.UnpackOutputs("selfId", common.LeftPadBytes([]byte{0xaa}, 32), &id); err != nil { + t.Fatalf("unpack one destination: %v", err) + } + if id != common.HexToAddress("0x00000000000000000000000000000000000000aa") { + t.Fatalf("selfId decoded to %s", id) + } + if err := contract.UnpackOutputs("create", data, &rid); err == nil { + t.Fatal("a missing destination was accepted") + } +} + +func TestUnpackOutputsRejectsNonPointerDestination(t *testing.T) { + contract, err := New(createFragment) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + var rid, mid common.Hash + data := append(rid.Bytes(), mid.Bytes()...) + if err := contract.UnpackOutputs("create", data, rid, mid); err == nil { + t.Fatal("non-pointer destinations were accepted") + } +} diff --git a/go-runtime/internal/artifacts/artifacts.go b/go-runtime/internal/artifacts/artifacts.go new file mode 100644 index 0000000..c7f8868 --- /dev/null +++ b/go-runtime/internal/artifacts/artifacts.go @@ -0,0 +1,163 @@ +// Package artifacts loads Foundry build artifacts. +// +// A forge artifact of the contract `Name` compiled from `Name.sol` lives at +// `/Name.sol/Name.json`, the layout that +// `src/xir_lab/native/deployer.py:_artifact` reads. Every field needed to +// broadcast a transaction is exposed: the parsed ABI (through +// internal/abiutil, so tuple arguments can be packed from the artifact's own +// ABI document), the creation bytecode, the deployed bytecode, the artifact +// path, and the sha256 of the artifact file. +package artifacts + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +// Artifact is one loaded forge artifact. +type Artifact struct { + // Name is the contract name, taken from the caller because a forge + // artifact file does not always repeat it. + Name string + // ABI is the parsed ABI document of the contract. + ABI *abiutil.Contract + // Bytecode is the creation bytecode without constructor arguments. + Bytecode []byte + // DeployedBytecode is the runtime bytecode the contract ships with. + DeployedBytecode []byte + // Path is the artifact file the values were read from. + Path string + // SHA256 is the digest of the artifact file, a provenance anchor for the + // deployment document. + SHA256 string +} + +// document is the subset of the forge artifact schema this package reads. +type document struct { + ABI json.RawMessage `json:"abi"` + Bytecode ctorObject `json:"bytecode"` + DeployedBytecode ctorObject `json:"deployedBytecode"` +} + +type ctorObject struct { + Object string `json:"object"` +} + +// Load reads `/.sol/.json`. +func Load(outRoot, contractName string) (Artifact, error) { + if err := checkContractName(contractName); err != nil { + return Artifact{}, err + } + return load(filepath.Join(outRoot, contractName+".sol"), contractName+".json", contractName) +} + +// LoadFrom reads `//.json` for artifacts +// whose source file name differs from the contract name, such as +// `StaticMessageIdMultisigIsmFactory` in `StaticMultisigIsm.sol`. A source +// file without the `.sol` suffix is completed. +func LoadFrom(outRoot, sourceFile, contractName string) (Artifact, error) { + if err := checkContractName(contractName); err != nil { + return Artifact{}, err + } + if strings.ContainsAny(sourceFile, `/\`) || sourceFile == "" { + return Artifact{}, fmt.Errorf("artifacts: invalid source file %q", sourceFile) + } + if !strings.HasSuffix(sourceFile, ".sol") { + sourceFile += ".sol" + } + return load(filepath.Join(outRoot, sourceFile), contractName+".json", contractName) +} + +// ConstructorData returns the deployment transaction data: the creation +// bytecode followed by the ABI-encoded constructor arguments. +func (a Artifact) ConstructorData(args ...any) ([]byte, error) { + if len(a.Bytecode) == 0 { + return nil, fmt.Errorf("artifacts: %s has no creation bytecode", a.describe()) + } + data := append([]byte(nil), a.Bytecode...) + if len(args) == 0 { + return data, nil + } + constructor := a.ABI.ABI().Constructor + if len(constructor.Inputs) != len(args) { + return nil, fmt.Errorf( + "artifacts: %s constructor takes %d arguments, got %d", + a.describe(), len(constructor.Inputs), len(args), + ) + } + encoded, err := constructor.Inputs.Pack(args...) + if err != nil { + return nil, fmt.Errorf("artifacts: pack %s constructor: %w", a.describe(), err) + } + return append(data, encoded...), nil +} + +func (a Artifact) describe() string { + if a.Name == "" { + return a.Path + } + return a.Name +} + +func load(dir, fileName, contractName string) (Artifact, error) { + path := filepath.Join(dir, fileName) + raw, err := os.ReadFile(path) + if err != nil { + return Artifact{}, fmt.Errorf("artifacts: read %s artifact %s: %w", contractName, path, err) + } + var parsed document + if err := json.Unmarshal(raw, &parsed); err != nil { + return Artifact{}, fmt.Errorf("artifacts: parse %s artifact %s: %w", contractName, path, err) + } + if len(parsed.ABI) == 0 { + return Artifact{}, fmt.Errorf("artifacts: %s artifact %s has no ABI", contractName, path) + } + contract, err := abiutil.New(string(parsed.ABI)) + if err != nil { + return Artifact{}, fmt.Errorf("artifacts: %s artifact %s: %w", contractName, path, err) + } + contract.SetName(contractName) + bytecode, err := decodeObject(parsed.Bytecode.Object) + if err != nil { + return Artifact{}, fmt.Errorf("artifacts: %s artifact %s bytecode: %w", contractName, path, err) + } + deployed, err := decodeObject(parsed.DeployedBytecode.Object) + if err != nil { + return Artifact{}, fmt.Errorf( + "artifacts: %s artifact %s deployedBytecode: %w", contractName, path, err, + ) + } + digest := sha256.Sum256(raw) + return Artifact{ + Name: contractName, + ABI: contract, + Bytecode: bytecode, + DeployedBytecode: deployed, + Path: path, + SHA256: hex.EncodeToString(digest[:]), + }, nil +} + +func decodeObject(object string) ([]byte, error) { + if object == "" { + return nil, nil + } + if !strings.HasPrefix(object, "0x") { + object = "0x" + object + } + return abiutil.DecodeHex(object) +} + +func checkContractName(name string) error { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + return fmt.Errorf("artifacts: invalid contract name %q", name) + } + return nil +} diff --git a/go-runtime/internal/artifacts/artifacts_test.go b/go-runtime/internal/artifacts/artifacts_test.go new file mode 100644 index 0000000..2f97e38 --- /dev/null +++ b/go-runtime/internal/artifacts/artifacts_test.go @@ -0,0 +1,270 @@ +package artifacts_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// repositoryRoot is the checkout that carries the untracked forge output. +func repositoryRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + return root +} + +// protocolRootOrSkip returns one protocol stack's forge output, or skips when +// the stack has not been built. The protocol projects are built in a developer +// checkout but not in CI, where only `contracts/out` exists. +func protocolRootOrSkip(t *testing.T, stack string) string { + t.Helper() + root := filepath.Join(repositoryRoot(t), "protocol-projects", stack, "out") + if info, err := os.Stat(root); err != nil || !info.IsDir() { + t.Skipf("protocol artifacts are absent: %s is not a directory", root) + } + return root +} + +func TestLoadXIRGateway(t *testing.T) { + outRoot := filepath.Join(repositoryRoot(t), "contracts", "out") + artifact, err := artifacts.Load(outRoot, "XIRGateway") + if err != nil { + t.Fatalf("load XIRGateway: %v", err) + } + path := filepath.Join(outRoot, "XIRGateway.sol", "XIRGateway.json") + if artifact.Path != path { + t.Errorf("artifact path = %q, want %q", artifact.Path, path) + } + if artifact.Name != "XIRGateway" { + t.Errorf("artifact name = %q", artifact.Name) + } + if len(artifact.Bytecode) == 0 { + t.Fatal("XIRGateway creation bytecode is empty") + } + if len(artifact.DeployedBytecode) == 0 { + t.Fatal("XIRGateway deployed bytecode is empty") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read artifact: %v", err) + } + digest := sha256.Sum256(raw) + if artifact.SHA256 != hex.EncodeToString(digest[:]) { + t.Errorf("artifact sha256 = %q, want %q", artifact.SHA256, hex.EncodeToString(digest[:])) + } + + registry := common.HexToAddress("0x00000000000000000000000000000000000000aa") + identifier, err := xir.GatewayTypedID(3133701) + if err != nil { + t.Fatalf("gateway typed id: %v", err) + } + bare, err := artifact.ConstructorData() + if err != nil { + t.Fatalf("constructor data without arguments: %v", err) + } + if !bytes.Equal(bare, artifact.Bytecode) { + t.Fatal("constructor data without arguments differs from the creation bytecode") + } + data, err := artifact.ConstructorData(registry, identifier.ABI()) + if err != nil { + t.Fatalf("constructor data: %v", err) + } + // head: address argument || offset of the TypedId tuple; tail: kind, + // offset of the identifier bytes, byte length, identifier bytes. + if len(data) != len(artifact.Bytecode)+6*32 { + t.Fatalf("constructor data length = %d, want %d", len(data), len(artifact.Bytecode)+6*32) + } + if !bytes.Equal(data[:len(artifact.Bytecode)], artifact.Bytecode) { + t.Fatal("constructor data does not start with the creation bytecode") + } + args := data[len(artifact.Bytecode):] + words := make([][]byte, 6) + for index := range words { + words[index] = args[index*32 : (index+1)*32] + } + if got := common.BytesToAddress(words[0][12:]); got != registry { + t.Errorf("first argument = %s, want %s", got, registry) + } + if new(big.Int).SetBytes(words[1]).Uint64() != 0x40 { + t.Errorf("TypedId tuple offset = %d, want 64", new(big.Int).SetBytes(words[1])) + } + if kind := words[2][31]; kind != xir.KindEVM { + t.Errorf("TypedId kind = %d, want %d", kind, xir.KindEVM) + } + if offset := new(big.Int).SetBytes(words[3]).Uint64(); offset != 0x40 { + t.Errorf("TypedId value offset = %d, want 64", offset) + } + if length := new(big.Int).SetBytes(words[4]).Uint64(); length != 20 { + t.Errorf("TypedId value length = %d, want 20", length) + } + if !bytes.Equal(words[5][:20], identifier.Value) { + t.Errorf("TypedId value = %x, want %x", words[5][:20], identifier.Value) + } + + // The encoded arguments must decode back to the values they came from. + decoded, err := artifact.ABI.ABI().Constructor.Inputs.Unpack(args) + if err != nil { + t.Fatalf("decode constructor arguments: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("decoded %d constructor arguments, want 2", len(decoded)) + } + gotRegistry, err := decodedAddress(decoded[0]) + if err != nil { + t.Fatalf("decode registry argument: %v", err) + } + if gotRegistry != registry { + t.Errorf("decoded registry = %s, want %s", gotRegistry, registry) + } + gotKind, gotValue, err := decodedTypedID(decoded[1]) + if err != nil { + t.Fatalf("decode identifier argument: %v", err) + } + if gotKind != identifier.Kind || !bytes.Equal(gotValue, identifier.Value) { + t.Errorf( + "decoded identifier = (%d, %x), want (%d, %x)", + gotKind, gotValue, identifier.Kind, identifier.Value, + ) + } + if _, err := artifact.ConstructorData(registry); err == nil { + t.Error("missing constructor arguments were accepted") + } +} + +// TestLoadEndpointV2 covers the LayerZero stack artifact. The protocol stacks +// are built in this checkout but not in CI, where only `contracts/out` is +// built, so the test skips when the stack has no forge output. +func TestLoadEndpointV2(t *testing.T) { + outRoot := protocolRootOrSkip(t, "layerzero-native") + artifact, err := artifacts.Load(outRoot, "EndpointV2") + if err != nil { + t.Fatalf("load EndpointV2: %v", err) + } + if len(artifact.Bytecode) == 0 || len(artifact.DeployedBytecode) == 0 { + t.Fatal("EndpointV2 bytecode is empty") + } + if len(artifact.ABI.ABI().Constructor.Inputs) != 2 { + t.Fatalf( + "EndpointV2 constructor inputs = %d, want 2", + len(artifact.ABI.ABI().Constructor.Inputs), + ) + } + owner := common.HexToAddress("0x00000000000000000000000000000000000000bb") + data, err := artifact.ConstructorData(uint32(3133701), owner) + if err != nil { + t.Fatalf("constructor data: %v", err) + } + if len(data) != len(artifact.Bytecode)+2*32 { + t.Fatalf("constructor data length = %d, want %d", len(data), len(artifact.Bytecode)+64) + } + if !bytes.Equal(data[:len(artifact.Bytecode)], artifact.Bytecode) { + t.Fatal("constructor data does not start with the creation bytecode") + } +} + +// TestLoadFromSourceFile covers artifacts whose source file name differs from +// the contract name, the only way to reach StaticMessageIdMultisigIsmFactory. +// Like the other protocol-stack test it skips when the stack has no forge +// output, which is the case in CI. +func TestLoadFromSourceFile(t *testing.T) { + outRoot := protocolRootOrSkip(t, "hyperlane-native") + if _, err := artifacts.Load(outRoot, "StaticMessageIdMultisigIsmFactory"); err == nil { + t.Error("same-named artifact path was accepted for a contract in another source file") + } + artifact, err := artifacts.LoadFrom( + outRoot, "StaticMultisigIsm.sol", "StaticMessageIdMultisigIsmFactory", + ) + if err != nil { + t.Fatalf("load StaticMessageIdMultisigIsmFactory: %v", err) + } + if len(artifact.Bytecode) == 0 { + t.Fatal("StaticMessageIdMultisigIsmFactory bytecode is empty") + } + data, err := artifact.ConstructorData() + if err != nil { + t.Fatalf("constructor data: %v", err) + } + if !bytes.Equal(data, artifact.Bytecode) { + t.Fatal("constructor data differs from the creation bytecode") + } +} + +func TestLoadFailsOnMissingInputs(t *testing.T) { + outRoot := t.TempDir() + if _, err := artifacts.Load(outRoot, "XIRGateway"); err == nil { + t.Fatal("missing artifact file was accepted") + } else if !strings.Contains(err.Error(), "XIRGateway") { + t.Errorf("missing artifact error does not name the contract: %v", err) + } + if _, err := artifacts.Load(outRoot, "../XIRGateway"); err == nil { + t.Fatal("path-traversing contract name was accepted") + } + writeFile(t, filepath.Join(outRoot, "NoABI.sol", "NoABI.json"), `{"bytecode":{"object":"0x00"}}`) + if _, err := artifacts.Load(outRoot, "NoABI"); err == nil { + t.Fatal("artifact without an ABI was accepted") + } else if !strings.Contains(err.Error(), "ABI") { + t.Errorf("missing ABI error does not mention the ABI: %v", err) + } +} + +func decodedAddress(value any) (common.Address, error) { + switch typed := value.(type) { + case common.Address: + return typed, nil + case *big.Int: + return common.BigToAddress(typed), nil + default: + return common.Address{}, fmt.Errorf("unsupported address value %T", value) + } +} + +// decodedTypedID reads the (uint8 kind, bytes value) tuple that go-ethereum +// decodes into an anonymous struct. +func decodedTypedID(value any) (uint8, []byte, error) { + reflected := reflect.ValueOf(value) + if reflected.Kind() != reflect.Struct || reflected.NumField() != 2 { + return 0, nil, fmt.Errorf("unsupported TypedId value %T", value) + } + numeric, ok := reflectUint(reflected.Field(0)) + if !ok { + return 0, nil, fmt.Errorf("TypedId kind field is %s", reflected.Field(0).Kind()) + } + raw, ok := reflected.Field(1).Interface().([]byte) + if !ok { + return 0, nil, fmt.Errorf("TypedId value field is %s", reflected.Field(1).Kind()) + } + return uint8(numeric), raw, nil +} + +func reflectUint(value reflect.Value) (uint64, bool) { + switch value.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return value.Uint(), true + default: + return 0, false + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/go-runtime/internal/deploy/abi.go b/go-runtime/internal/deploy/abi.go new file mode 100644 index 0000000..f18606f --- /dev/null +++ b/go-runtime/internal/deploy/abi.go @@ -0,0 +1,122 @@ +package deploy + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" +) + +// This file holds the Go shapes of the ABI tuples the deployment packs. Every +// field carries an explicit `abi:"name"` tag so go-ethereum maps it to the +// component name of the artifact ABI, and a tag that no longer matches the +// artifact is a hard error instead of a silently reordered tuple. + +// registryRootSnapshot is the XIRRegistry.RootSnapshot tuple that +// `setRoot(version, snapshot)` takes. +type registryRootSnapshot struct { + GatewayHash [32]byte `abi:"gatewayHash"` + Signer common.Address `abi:"signer"` + ValidAfter uint64 `abi:"validAfter"` + ValidUntil uint64 `abi:"validUntil"` + Enabled bool `abi:"enabled"` +} + +// registryProfileSnapshot is the XIRRegistry.ProfileSnapshot tuple that +// `setProfile(profileHash, snapshot)` takes. +type registryProfileSnapshot struct { + SourceHash [32]byte `abi:"srcHash"` + DestinationHash [32]byte `abi:"dstHash"` + Adapter common.Address `abi:"adapter"` + SecurityLevel uint8 `abi:"securityLevel"` + ValidAfter uint64 `abi:"validAfter"` + ValidUntil uint64 `abi:"validUntil"` + Enabled bool `abi:"enabled"` +} + +// ulnConfig is the LayerZero UlnConfig tuple. +type ulnConfig struct { + Confirmations uint64 `abi:"confirmations"` + RequiredDVNCount uint8 `abi:"requiredDVNCount"` + OptionalDVNCount uint8 `abi:"optionalDVNCount"` + OptionalDVNThreshold uint8 `abi:"optionalDVNThreshold"` + RequiredDVNs []common.Address `abi:"requiredDVNs"` + OptionalDVNs []common.Address `abi:"optionalDVNs"` +} + +// setDefaultUlnConfigParam is one element of SendUln302/ReceiveUln302 +// `setDefaultUlnConfigs`. +type setDefaultUlnConfigParam struct { + EID uint32 `abi:"eid"` + Config ulnConfig `abi:"config"` +} + +// executorConfig is the LayerZero ExecutorConfig tuple. +type executorConfig struct { + MaxMessageSize uint32 `abi:"maxMessageSize"` + Executor common.Address `abi:"executor"` +} + +// setDefaultExecutorConfigParam is one element of SendUln302 +// `setDefaultExecutorConfigs`. +type setDefaultExecutorConfigParam struct { + EID uint32 `abi:"eid"` + Config executorConfig `abi:"config"` +} + +// dvnDstConfigParam is one element of DVN `setDstConfig`. +type dvnDstConfigParam struct { + DestinationEID uint32 `abi:"dstEid"` + Gas uint64 `abi:"gas"` + MultiplierBps uint16 `abi:"multiplierBps"` + FloorMarginUSD *big.Int `abi:"floorMarginUSD"` +} + +// executorDstConfigParam is one element of Executor `setDstConfig`. +type executorDstConfigParam struct { + DestinationEID uint32 `abi:"dstEid"` + LzReceiveBaseGas uint64 `abi:"lzReceiveBaseGas"` + LzComposeBaseGas uint64 `abi:"lzComposeBaseGas"` + MultiplierBps uint16 `abi:"multiplierBps"` + FloorMarginUSD *big.Int `abi:"floorMarginUSD"` + NativeCap *big.Int `abi:"nativeCap"` +} + +// priceFeedPrice is the ILayerZeroPriceFeed.Price tuple. +type priceFeedPrice struct { + PriceRatio *big.Int `abi:"priceRatio"` + GasPriceInUnit uint64 `abi:"gasPriceInUnit"` + GasPerByte uint32 `abi:"gasPerByte"` +} + +// priceFeedUpdate is one element of PriceFeed `setPrice`. +type priceFeedUpdate struct { + EID uint32 `abi:"eid"` + Price priceFeedPrice `abi:"price"` +} + +// receiveOptions builds the official Type-3 Executor LZ_RECEIVE options with +// zero native value, byte for byte +// `src/xir_lab/native/layerzero.py:executor_lz_receive_options`. Slice D owns +// internal/layerzero; this copy exists because that package does not exist yet +// and the deployment needs the same bytes to configure the adapters. +func receiveOptions(gasLimit uint64) ([]byte, error) { + if gasLimit == 0 { + return nil, fmt.Errorf("deploy: layerzero executor gas limit is out of uint128 range") + } + options := make([]byte, 0, 22) + options = append(options, 0x00, 0x03) // option type 3, one option + options = append(options, 0x01) // option length + options = append(options, 0x00, 0x11) // worker id 17, the executor + options = append(options, 0x01) // option length + var limit [16]byte + for index := len(limit) - 1; index >= 0; index-- { + limit[index] = byte(gasLimit) + gasLimit >>= 8 + } + return append(options, limit[:]...), nil +} + +// receiveOptionsGasLimit is the gas limit every adapter is configured with +// (src/xir_lab/native/multihop_deployer.py `_configure_route_adapters`). +const receiveOptionsGasLimit uint64 = 1_500_000 diff --git a/go-runtime/internal/deploy/chain.go b/go-runtime/internal/deploy/chain.go new file mode 100644 index 0000000..4b75556 --- /dev/null +++ b/go-runtime/internal/deploy/chain.go @@ -0,0 +1,336 @@ +package deploy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strconv" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +// chainRuntime is one chain of the deployment: the client that reaches it, the +// transactor that makes every send durable, and the per-chain action ordinal +// that keeps action ids stable across a re-run. +type chainRuntime struct { + spec ChainSpec + client *evm.Client + transactor *evm.Transactor + store *state.Store + deployer common.Address + ordinal int + journal *journal + owner *deployment + phase string +} + +func newChainRuntime( + ctx context.Context, + spec ChainSpec, + options Options, + store *state.Store, + spoolRoot string, + entries *journal, +) (*chainRuntime, error) { + client, err := evm.Dial(ctx, spec.RPCEndpoint, rpcTimeout) + if err != nil { + return nil, fmt.Errorf("chain %s: %w", spec.Role, err) + } + chainID, err := client.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("chain %s: read chain id: %w", spec.Role, err) + } + if chainID.Uint64() != spec.ChainID { + return nil, fmt.Errorf( + "chain %s reports chain id %s but the spec says %d", + spec.Role, chainID, spec.ChainID, + ) + } + signer, err := evm.NewSigner(options.DeployerKey, chainID) + if err != nil { + return nil, fmt.Errorf("chain %s: %w", spec.Role, err) + } + transactor, err := evm.NewTransactor(client, signer, store, spoolRoot, txTimeout) + if err != nil { + return nil, fmt.Errorf("chain %s: create transactor: %w", spec.Role, err) + } + return &chainRuntime{ + spec: spec, + client: client, + transactor: transactor, + store: store, + deployer: signer.Address(), + journal: entries, + }, nil +} + +// deploy broadcasts one CREATE and returns the deployed address. +func (c *chainRuntime) deploy( + ctx context.Context, + key string, + artifact artifacts.Artifact, + args ...any, +) (common.Address, error) { + data, err := artifact.ConstructorData(args...) + if err != nil { + return common.Address{}, err + } + label := fmt.Sprintf("deploy:%s:%s", key, artifact.Name) + record, err := c.execute(ctx, label, artifact.SHA256, evm.TxRequest{ + DeployCode: data, + Value: new(big.Int), + }) + if err != nil { + return common.Address{}, err + } + address := common.HexToAddress(record.Target) + code, err := c.client.CodeAt(ctx, address) + if err != nil { + return common.Address{}, fmt.Errorf("chain %s: read runtime code of %s: %w", c.spec.Role, key, err) + } + if len(code) == 0 { + return common.Address{}, fmt.Errorf( + "chain %s: deployed %s at %s has no runtime code", + c.spec.Role, artifact.Name, address, + ) + } + return address, nil +} + +// call broadcasts one state-changing call and returns its record. +func (c *chainRuntime) call( + ctx context.Context, + key string, + to common.Address, + artifact artifacts.Artifact, + method string, + args ...any, +) (actionRecord, error) { + data, err := artifact.ABI.PackCall(method, args...) + if err != nil { + return actionRecord{}, err + } + label := fmt.Sprintf("call:%s:%s", key, method) + return c.execute(ctx, label, artifact.SHA256, evm.TxRequest{ + To: to, + Data: data, + Value: new(big.Int), + }) +} + +// callResult broadcasts one state-changing call whose return value the +// deployment needs, which is read with a preceding eth_call at the same +// address and calldata. Only the factory that creates the ISM needs this. +func (c *chainRuntime) callResult( + ctx context.Context, + key string, + to common.Address, + artifact artifacts.Artifact, + method string, + args ...any, +) ([]byte, error) { + data, err := artifact.ABI.PackCall(method, args...) + if err != nil { + return nil, err + } + output, err := c.client.CallContract(ctx, to, data) + if err != nil { + return nil, fmt.Errorf("chain %s: simulate %s.%s: %w", c.spec.Role, key, method, err) + } + if _, err := c.call(ctx, key, to, artifact, method, args...); err != nil { + return nil, err + } + return output, nil +} + +// execute sends one transaction through the durable transactor and appends its +// provenance to the deployment journal. +// +// An action the ledger already holds as succeeded is returned from the durable +// row without a second broadcast, and does not add a second journal row: a +// re-run of the same deployment against the same runtime root therefore +// reproduces the same document instead of duplicating sends or evidence. +func (c *chainRuntime) execute( + ctx context.Context, + label string, + artifactSHA256 string, + request evm.TxRequest, +) (actionRecord, error) { + c.ordinal++ + payload := request.Data + if len(request.DeployCode) > 0 { + payload = request.DeployCode + } + calldataSHA256 := sha256Hex(payload) + request.ActionID = actionID(c.spec.Role, c.spec.ChainID, c.ordinal, label, calldataSHA256) + request.AttemptID = deploymentAttempt + request.Stage = actionStage(c.spec.Role, c.ordinal, label) + request.ChainRole = c.spec.Role + previous, err := c.store.Action(request.ActionID) + if err != nil { + return actionRecord{}, fmt.Errorf("chain %s: read action %s: %w", c.spec.Role, label, err) + } + replay := previous != nil && previous.State == state.ActionSucceeded + result, err := c.transactor.Execute(ctx, request) + if err != nil { + return actionRecord{}, fmt.Errorf("chain %s: %s: %w", c.spec.Role, label, err) + } + record, err := c.provenance( + ctx, request.ActionID, label, artifactSHA256, payload, calldataSHA256, result, + ) + if err != nil { + return actionRecord{}, err + } + c.owner.addAction(c.phase, record) + if replay { + return record, nil + } + if err := c.journal.append(record); err != nil { + return actionRecord{}, err + } + return record, nil +} + +// provenance assembles the durable evidence row of one action: the transaction +// identity, the receipt the transactor persisted, and the on-chain facts the +// Python deployment document records for the same action. +func (c *chainRuntime) provenance( + ctx context.Context, + actionID string, + label string, + artifactSHA256 string, + payload []byte, + calldataSHA256 string, + result state.ActionResult, +) (actionRecord, error) { + target := common.HexToAddress(result.ContractAddress) + if target == (common.Address{}) && result.Detail != nil { + target = common.HexToAddress(detailString(result.Detail, "target")) + } + record := actionRecord{ + ActionID: actionID, + Role: c.spec.Role, + ChainID: c.spec.ChainID, + Nonce: detailUint64(result.Detail, "nonce"), + Action: label, + Target: target.Hex(), + CalldataSHA256: calldataSHA256, + CalldataBytes: len(payload), + TransactionHash: result.TransactionHash, + RawSHA256: detailString(result.Detail, "raw_sha256"), + ReceiptPath: result.ReceiptPath, + ReceiptSHA256: result.ReceiptSHA256, + // The transactor fails an action whose receipt reverted, so a + // returned result is always a status 1 receipt. + Status: 1, + GasUsed: result.GasUsed, + BlockNumber: result.BlockNumber, + ArtifactSHA256: artifactSHA256, + } + code, err := c.client.CodeAt(ctx, target) + if err != nil { + return actionRecord{}, fmt.Errorf("chain %s: read runtime code of %s: %w", c.spec.Role, label, err) + } + if len(code) == 0 { + return actionRecord{}, fmt.Errorf( + "chain %s: %s produced no runtime code at %s", c.spec.Role, label, target, + ) + } + runtimeDigest := sha256.Sum256(code) + record.RuntimeCodeSHA256 = hex.EncodeToString(runtimeDigest[:]) + if header, err := c.client.HeaderByNumber(ctx, new(big.Int).SetUint64(result.BlockNumber)); err == nil { + record.BlockTimestamp = header.Time + } + return record, nil +} + +// actionID identifies one durable deployment action. The Python deployer +// hashes the reserved nonce into the id; the Go transactor reserves the nonce +// itself, so the per-chain action ordinal takes that place and the id stays +// reproducible across a re-run. +func actionID(role string, chainID uint64, ordinal int, label, calldataSHA256 string) string { + return sha256Hex([]byte(fmt.Sprintf( + "%s:%d:%d:%s:%s", role, chainID, ordinal, label, calldataSHA256, + ))) +} + +// actionStage is the ledger key of one action. It carries the role and the +// ordinal because the same label repeats on several chains and, for prior +// verifier bindings, several times on one adapter. +func actionStage(role string, ordinal int, label string) string { + return fmt.Sprintf("%s_%03d_%s", role, ordinal, label) +} + +func sha256Hex(payload []byte) string { + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]) +} + +// detailString reads one string out of the transactor's receipt detail, which +// is a JSON document restored from the durable row. +func detailString(detail map[string]any, key string) string { + if detail == nil { + return "" + } + value, ok := detail[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64, bool, int64, uint64: + return fmt.Sprintf("%v", typed) + default: + return "" + } +} + +// detailUint64 reads one numeric field out of the receipt detail. +func detailUint64(detail map[string]any, key string) uint64 { + if detail == nil { + return 0 + } + value, ok := detail[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case uint64: + return typed + case int64: + return uint64(typed) + case float64: + return uint64(typed) + case json.Number: + parsed, err := typed.Int64() + if err != nil { + return 0 + } + return uint64(parsed) + case string: + parsed, err := strconv.ParseUint(typed, 10, 64) + if err != nil { + return 0 + } + return parsed + default: + return 0 + } +} + +// rpcTimeout bounds one JSON-RPC request and txTimeout one mined receipt wait, +// matching the Python deployer's 180 second receipt window. +const ( + rpcTimeout = 30 * time.Second + txTimeout = 180 * time.Second +) diff --git a/go-runtime/internal/deploy/deploy.go b/go-runtime/internal/deploy/deploy.go new file mode 100644 index 0000000..80efd65 --- /dev/null +++ b/go-runtime/internal/deploy/deploy.go @@ -0,0 +1,569 @@ +// Package deploy deploys one XIR application and the protocol stacks it runs +// on, then records what was deployed. +// +// The sequence is the Go port of two Python/Solidity references: +// +// - `src/xir_lab/native/multihop_deployer.py`, which deploys the registries, +// gateways, receivers, transition recorders and route adapters of one +// A..E application and configures the adapters and registries; +// - `protocol-projects/hyperlane-native/script/DeployHyperlaneNative.s.sol` +// and `protocol-projects/layerzero-native/script/DeployLayerZeroNative.s.sol`, +// which bootstrap the Hyperlane and LayerZero native stacks on each chain +// from the same Forge artifacts, with the same constructors, the same +// configuration calls, and the same order. +// +// Every send goes through internal/evm's durable transactor, so the intent, the +// signed bytes and the receipt are committed before the corresponding network +// step, and the receipt is spooled under the private runtime root. The +// deployment journal records one row per completed action and the deployment +// document describes the deployed contracts, the carrier bootstrap, the +// classification of the gas the application actions consumed, and the digest of +// the journal. +// +// Three deliberate differences from the Python deployer: +// +// - only the routes the caller requests are deployed and configured, where +// the Python campaign always deploys the whole route universe; +// - the durable ledger is the transactor's action table, so the journal holds +// one row per action instead of the Python journal's intent/signed/ +// submitted/succeeded lifecycle; +// - the carrier documents are also written where the Python tooling reads +// them (/hyperlane/... and /layerzero/...), so a +// Python consumer sees the tree it expects. The evidence pipeline's start +// and end block bookkeeping is not reproduced: it belongs to analysis, not +// to deployment. +package deploy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// Deployment document identity. The schema version and the namespace are the +// ones `multihop_deployer._deployment_document` writes, so a Python consumer +// reads the file unchanged. +const ( + documentSchemaVersion = "xir-lab-native-multihop-deployment-v1" + deploymentNamespace = "native-multihop-switching-v1" + + // deploymentAttempt and the action stage keys label the durable ledger + // rows of a deployment, which is not an attempt of the runner. + deploymentAttempt = "deployment" +) + +// chainRoles is the frozen role order of the five-chain profile +// (multihop_deployer.CHAIN_ROLES). +var chainRoles = []string{"a", "b", "c", "d", "e"} + +// routeOrder is the preregistered route universe +// (multihop_scalability.ROUTE_ORDER). Only the routes a caller requests are +// deployed and configured. +var routeOrder = []string{ + "H", "L", "HH", "HHH", "HHHH", "HL", "HLH", "HLHL", "LHLH", "HHL", "HHHL", +} + +// ChainSpec is one chain the deployment writes to. +type ChainSpec struct { + // Role is the profile role, "a" through "e". + Role string + // ChainID is the EVM chain id the endpoint must report. + ChainID uint64 + // RPCEndpoint is the HTTP JSON-RPC endpoint. + RPCEndpoint string + // HyperlaneDomain is the local Hyperlane domain of the chain. + HyperlaneDomain uint32 + // LayerZeroEID is the LayerZero endpoint id of the chain. + LayerZeroEID uint32 +} + +// RoleAddresses are the addresses the deployed contracts bind to. The +// deployment only sends from the caller's deployer key, so none of them needs +// a balance. +type RoleAddresses struct { + // Runner is the XIR runner: the adapter administrator-approved sender and + // the transition recorder's authority. + Runner common.Address + // RootSigner signs XIR roots that the registries accept. + RootSigner common.Address + // LayerZeroWorker is the LayerZero DVN signer and Executor submitter. + LayerZeroWorker common.Address + // HyperlaneValidator is the validator of the Hyperlane multisig ISM. + HyperlaneValidator common.Address + // HyperlaneRelayer submits Hyperlane process calls. No on-chain binding + // exists for a relayer, so the deployment only records it. + HyperlaneRelayer common.Address +} + +// Options configure one deployment. +type Options struct { + // ArtifactsRoot is the XIR application forge output, normally + // /contracts/out. + ArtifactsRoot string + // ProtocolArtifactsRoot is the protocol project root, normally + // /protocol-projects; the Hyperlane and LayerZero artifacts + // are read from /hyperlane-native/out and /layerzero-native/out. + ProtocolArtifactsRoot string + // RuntimeRoot holds the private deployment journal, the receipt spool and + // the chain registry documents. Nothing here is committed to the repository. + RuntimeRoot string + // DeployerKey is the hex private key that signs every deployment + // transaction. It is never logged or written. + DeployerKey string + // OutputPath is the deployment document to write, atomically. + OutputPath string + // Routes selects the preregistered routes to deploy and configure, a + // subset of routeOrder. The order of the slice does not matter. + Routes []string + // RoleAddresses are the addresses the contracts bind to. + RoleAddresses +} + +// GatewayTypedIDDocument is the typed gateway identifier of one chain. +type GatewayTypedIDDocument struct { + Kind uint8 `json:"kind"` + Value string `json:"value"` + Hash string `json:"hash"` +} + +// RouteHopDocument is one hop of a deployed route. +type RouteHopDocument struct { + HopIndex int `json:"hop_index"` + Protocol string `json:"protocol"` + SourceChain string `json:"source_chain"` + DestinationChain string `json:"destination_chain"` + ProfileHash string `json:"profile_hash"` + OutboundAdapter string `json:"outbound_adapter"` + InboundAdapter string `json:"inbound_adapter"` +} + +// RouteDocument is one deployed route. +type RouteDocument struct { + DestinationChain string `json:"destination_chain"` + Receiver string `json:"receiver"` + Hops []RouteHopDocument `json:"hops"` +} + +// RoleAddressesDocument records the addresses every deployed contract binds to. +type RoleAddressesDocument struct { + Runner string `json:"runner"` + RootSigner string `json:"root_signer"` + LayerZeroWorker string `json:"layerzero_worker"` + HyperlaneValidator string `json:"hyperlane_validator"` + HyperlaneRelayer string `json:"hyperlane_relayer"` +} + +// ClassifiedGasDocument is one gas classification of the deployment. +type ClassifiedGasDocument struct { + Classification string `json:"classification"` + TransactionCount int `json:"transaction_count"` + GasUsed uint64 `json:"gas_used"` +} + +// ComponentGasDocument summarises the unit observations of one component. +type ComponentGasDocument struct { + Component string `json:"component"` + ObservationCount int `json:"observation_count"` + MinimumGas uint64 `json:"minimum_gas"` + MaximumGas uint64 `json:"maximum_gas"` + MeanGas float64 `json:"mean_gas"` +} + +// DeploymentGasDocument is the gas accounting of the application deployment, +// shaped like `multihop_deployer._deployment_document`'s `deployment_gas`. +type DeploymentGasDocument struct { + TransactionCount int `json:"transaction_count"` + GasUsed uint64 `json:"gas_used"` + Scope string `json:"scope"` + ReusedNativeCarrierGasIncluded bool `json:"reused_native_carrier_gas_included"` + ReusedNativeCarrierComponents []string `json:"reused_native_carrier_components"` + ClassifiedTotals []ClassifiedGasDocument `json:"classified_totals"` + ComponentUnitObservations []ComponentGasDocument `json:"component_unit_observations"` + Receipts []actionRecord `json:"receipts"` +} + +// ChainGasDocument is the gas of one chain's protocol bootstrap. +type ChainGasDocument struct { + TransactionCount int `json:"transaction_count"` + GasUsed uint64 `json:"gas_used"` +} + +// ProtocolGasDocument is the gas of the carrier bootstrap, which the Python +// deployment document does not classify because the carriers are reused. +type ProtocolGasDocument struct { + TransactionCount int `json:"transaction_count"` + GasUsed uint64 `json:"gas_used"` + ByChain map[string]ChainGasDocument `json:"by_chain"` + Hyperlane ChainGasDocument `json:"hyperlane"` + LayerZero ChainGasDocument `json:"layerzero"` +} + +// Document is the deployment document written to Options.OutputPath. +type Document struct { + SchemaVersion string `json:"schema_version"` + Namespace string `json:"namespace"` + Deployer string `json:"deployer"` + Runner string `json:"runner"` + RootSigner string `json:"root_signer"` + RegistryVersion uint32 `json:"registry_version"` + GatewayTypedIDs map[string]GatewayTypedIDDocument `json:"gateway_typed_ids"` + Routes map[string]RouteDocument `json:"routes"` + Chains map[string]map[string]string `json:"chains"` + Infrastructure map[string]map[string]string `json:"infrastructure"` + RoleAddresses RoleAddressesDocument `json:"role_addresses"` + ProtocolDeployments map[string]ProtocolDeploymentDocument `json:"protocol_deployments"` + ProtocolGas ProtocolGasDocument `json:"protocol_gas"` + DeploymentGas DeploymentGasDocument `json:"deployment_gas"` + JournalSHA256 string `json:"journal_sha256"` + // DeploymentSHA256 is the digest of the written document. It is returned + // in memory only, exactly as `multihop_deployer.run` does, so the digest + // of the file on disk stays reproducible. + DeploymentSHA256 string `json:"deployment_sha256,omitempty"` +} + +// actionRecord is one durable deployment action, the journal row and the +// receipt provenance entry of the deployment document. +type actionRecord struct { + ActionID string `json:"action_id"` + Role string `json:"role"` + ChainID uint64 `json:"chain_id"` + Nonce uint64 `json:"nonce"` + Action string `json:"action"` + Target string `json:"target_or_created_address"` + CalldataSHA256 string `json:"calldata_sha256"` + CalldataBytes int `json:"calldata_bytes"` + TransactionHash string `json:"transaction_hash"` + RawSHA256 string `json:"raw_sha256,omitempty"` + ReceiptPath string `json:"receipt_path,omitempty"` + ReceiptSHA256 string `json:"receipt_sha256"` + Status uint64 `json:"status"` + GasUsed uint64 `json:"gas_used"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp uint64 `json:"block_timestamp_utc_seconds,omitempty"` + RuntimeCodeSHA256 string `json:"runtime_code_sha256"` + ArtifactSHA256 string `json:"compiled_artifact_sha256,omitempty"` + Classification string `json:"cost_classification,omitempty"` +} + +// Deploy deploys the protocol stacks and one XIR application, writes the +// deployment document to Options.OutputPath, and returns it. +func Deploy(ctx context.Context, chains []ChainSpec, options Options) (Document, error) { + run, err := newDeployment(ctx, chains, options) + if err != nil { + return Document{}, err + } + defer run.close() + if err := run.bootstrapProtocols(ctx); err != nil { + return Document{}, err + } + if err := run.deployApplication(ctx); err != nil { + return Document{}, err + } + return run.document() +} + +// deployment is one running deployment. +type deployment struct { + options Options + specs []ChainSpec + routes []string + layout layout + chains map[string]*chainRuntime + order []string + store *state.Store + entries *journal + deployer common.Address + manifest map[string]map[string]string + stack map[string]*protocolStack + appRecords []actionRecord + protocolRecords []actionRecord + gatewayIDs map[string]xir.TypedID + gatewayHashes map[string][32]byte + profileHashes map[string]map[int][32]byte + artifactCache map[string]artifactCacheEntry + protocolDocuments map[string]ProtocolDeploymentDocument +} + +// layout is the private runtime tree of one deployment. +type layout struct { + work string + journal string + spool string + state string + hyperlaneNative string + hyperlaneRegistry string + layerZero string +} + +func newLayout(runtimeRoot string) layout { + work := filepath.Join(runtimeRoot, "deploy") + return layout{ + work: work, + journal: filepath.Join(work, "deployment-journal.jsonl"), + spool: filepath.Join(work, "receipts"), + state: filepath.Join(work, "deployment.sqlite"), + hyperlaneNative: filepath.Join(runtimeRoot, "hyperlane", "native-deployments"), + hyperlaneRegistry: filepath.Join(runtimeRoot, "hyperlane", "registry", "chains"), + layerZero: filepath.Join(runtimeRoot, "layerzero", "deployments"), + } +} + +type artifactCacheEntry struct { + artifact artifacts.Artifact + err error +} + +func (r *deployment) close() { + if r.entries != nil { + _ = r.entries.close() + } + if r.store != nil { + _ = r.store.Close() + } +} + +// document assembles the deployment document, writes it atomically, and +// returns it with the digest of the written bytes. +func (r *deployment) document() (Document, error) { + journalSHA256, err := r.entries.digest() + if err != nil { + return Document{}, err + } + document := Document{ + SchemaVersion: documentSchemaVersion, + Namespace: deploymentNamespace, + Deployer: strings.ToLower(r.deployer.Hex()), + Runner: strings.ToLower(r.options.Runner.Hex()), + RootSigner: strings.ToLower(r.options.RootSigner.Hex()), + RegistryVersion: xir.RegistryVersion, + GatewayTypedIDs: r.gatewayDocuments(), + Routes: r.routeDocuments(), + Chains: r.manifest, + Infrastructure: r.infrastructureDocument(), + RoleAddresses: r.roleAddressesDocument(), + ProtocolDeployments: r.protocolDocuments, + ProtocolGas: r.protocolGasDocument(), + DeploymentGas: r.deploymentGasDocument(), + JournalSHA256: journalSHA256, + } + payload, err := json.MarshalIndent(document, "", " ") + if err != nil { + return Document{}, fmt.Errorf("deploy: encode deployment document: %w", err) + } + payload = append(payload, '\n') + if err := writeAtomic(r.options.OutputPath, payload); err != nil { + return Document{}, err + } + digest := sha256.Sum256(payload) + document.DeploymentSHA256 = hex.EncodeToString(digest[:]) + return document, nil +} + +func (r *deployment) gatewayDocuments() map[string]GatewayTypedIDDocument { + out := make(map[string]GatewayTypedIDDocument, len(r.gatewayIDs)) + for _, role := range r.order { + identifier := r.gatewayIDs[role] + out[strings.ToUpper(role)] = GatewayTypedIDDocument{ + Kind: identifier.Kind, + Value: "0x" + hex.EncodeToString(identifier.Value), + Hash: xir.FormatDigest(r.gatewayHashes[role]), + } + } + return out +} + +func (r *deployment) routeDocuments() map[string]RouteDocument { + out := make(map[string]RouteDocument, len(r.routes)) + for _, route := range r.routes { + hops := make([]RouteHopDocument, 0, len(route)) + for hopIndex := 1; hopIndex <= len(route); hopIndex++ { + source := r.order[hopIndex-1] + destination := r.order[hopIndex] + outboundKey, _ := xir.AdapterKey(route, hopIndex, "out") + inboundKey, _ := xir.AdapterKey(route, hopIndex, "in") + hops = append(hops, RouteHopDocument{ + HopIndex: hopIndex, + Protocol: string(route[hopIndex-1]), + SourceChain: strings.ToUpper(source), + DestinationChain: strings.ToUpper(destination), + ProfileHash: xir.FormatDigest(r.profileHashes[route][hopIndex]), + OutboundAdapter: strings.ToLower(r.manifest[source][outboundKey]), + InboundAdapter: strings.ToLower(r.manifest[destination][inboundKey]), + }) + } + destination := r.order[len(route)] + out[route] = RouteDocument{ + DestinationChain: strings.ToUpper(destination), + Receiver: strings.ToLower(r.manifest[destination]["receiver"]), + Hops: hops, + } + } + return out +} + +func (r *deployment) roleAddressesDocument() RoleAddressesDocument { + return RoleAddressesDocument{ + Runner: strings.ToLower(r.options.Runner.Hex()), + RootSigner: strings.ToLower(r.options.RootSigner.Hex()), + LayerZeroWorker: strings.ToLower(r.options.LayerZeroWorker.Hex()), + HyperlaneValidator: strings.ToLower(r.options.HyperlaneValidator.Hex()), + HyperlaneRelayer: strings.ToLower(r.options.HyperlaneRelayer.Hex()), + } +} + +// deploymentGasDocument classifies the application actions exactly like +// `multihop_deployer.classify_multihop_deployment_costs`: contract deployments +// are XIR-only cost, every configuration call initialises a profile, root or +// peer binding. The carrier bootstrap is reported separately because the +// carriers are reused by the campaign. +func (r *deployment) deploymentGasDocument() DeploymentGasDocument { + componentGas := map[string][]uint64{} + classifiedGas := map[string][]uint64{} + records := make([]actionRecord, 0, len(r.appRecords)) + for _, record := range r.appRecords { + classification := "xir_profile_root_peer_binding_initialization" + component := "configuration_call" + if strings.HasPrefix(record.Action, "deploy:") { + classification = "xir_only_contract_deployment" + component = record.Action[strings.LastIndex(record.Action, ":")+1:] + } + record.Classification = classification + componentGas[component] = append(componentGas[component], record.GasUsed) + classifiedGas[classification] = append(classifiedGas[classification], record.GasUsed) + records = append(records, record) + } + total := uint64(0) + for _, record := range records { + total += record.GasUsed + } + document := DeploymentGasDocument{ + TransactionCount: len(records), + GasUsed: total, + Scope: "experiment_route_isolation_total_not_single_gateway_cost", + ReusedNativeCarrierGasIncluded: false, + ReusedNativeCarrierComponents: []string{ + "Hyperlane Mailbox/ISM/validator/relayer", + "LayerZero Endpoint/ULN/DVN/Executor/worker", + }, + Receipts: records, + } + for _, classification := range sortedKeys(classifiedGas) { + values := classifiedGas[classification] + document.ClassifiedTotals = append(document.ClassifiedTotals, ClassifiedGasDocument{ + Classification: classification, + TransactionCount: len(values), + GasUsed: sum(values), + }) + } + for _, component := range sortedKeys(componentGas) { + values := componentGas[component] + document.ComponentUnitObservations = append( + document.ComponentUnitObservations, + ComponentGasDocument{ + Component: component, + ObservationCount: len(values), + MinimumGas: minimum(values), + MaximumGas: maximum(values), + MeanGas: float64(sum(values)) / float64(len(values)), + }, + ) + } + return document +} + +func (r *deployment) protocolGasDocument() ProtocolGasDocument { + document := ProtocolGasDocument{ByChain: map[string]ChainGasDocument{}} + for _, role := range r.order { + gas := ChainGasDocument{} + for _, record := range r.protocolRecords { + if record.Role != role { + continue + } + gas.TransactionCount++ + gas.GasUsed += record.GasUsed + if strings.Contains(record.Action, ":hyperlane:") { + document.Hyperlane.TransactionCount++ + document.Hyperlane.GasUsed += record.GasUsed + } else { + document.LayerZero.TransactionCount++ + document.LayerZero.GasUsed += record.GasUsed + } + } + document.ByChain[role] = gas + document.TransactionCount += gas.TransactionCount + document.GasUsed += gas.GasUsed + } + return document +} + +// writeAtomic writes payload to path through a temporary file in the same +// directory, fsynced before the rename, so a reader never sees a partial +// document. +func writeAtomic(path string, payload []byte) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("deploy: create %s: %w", directory, err) + } + file, err := os.CreateTemp(directory, ".deployment-*.json") + if err != nil { + return fmt.Errorf("deploy: create temporary document: %w", err) + } + temporary := file.Name() + defer func() { _ = os.Remove(temporary) }() + if _, err := file.Write(payload); err != nil { + file.Close() + return fmt.Errorf("deploy: write %s: %w", temporary, err) + } + if err := file.Sync(); err != nil { + file.Close() + return fmt.Errorf("deploy: sync %s: %w", temporary, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("deploy: close %s: %w", temporary, err) + } + if err := os.Chmod(temporary, 0o644); err != nil { + return fmt.Errorf("deploy: chmod %s: %w", temporary, err) + } + if err := os.Rename(temporary, path); err != nil { + return fmt.Errorf("deploy: publish %s: %w", path, err) + } + return nil +} + +func sortedKeys[V any](values map[string]V) []string { + return slices.Sorted(maps.Keys(values)) +} + +func sum(values []uint64) uint64 { + total := uint64(0) + for _, value := range values { + total += value + } + return total +} + +func minimum(values []uint64) uint64 { + if len(values) == 0 { + return 0 + } + return slices.Min(values) +} + +func maximum(values []uint64) uint64 { + if len(values) == 0 { + return 0 + } + return slices.Max(values) +} diff --git a/go-runtime/internal/deploy/deploy_test.go b/go-runtime/internal/deploy/deploy_test.go new file mode 100644 index 0000000..40985a2 --- /dev/null +++ b/go-runtime/internal/deploy/deploy_test.go @@ -0,0 +1,1203 @@ +package deploy_test + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/deploy" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/lab" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// hlRouteSpecs are the three chains of the HL route with the frozen profile +// coordinates (configs/profiles/native-multihop-five-chain-v1.json). +func hlRouteSpecs() []deploy.ChainSpec { + return []deploy.ChainSpec{ + {Role: "a", ChainID: 3133701, HyperlaneDomain: 3133701, LayerZeroEID: 49001}, + {Role: "b", ChainID: 3133702, HyperlaneDomain: 3133702, LayerZeroEID: 49002}, + {Role: "c", ChainID: 3133703, HyperlaneDomain: 3133703, LayerZeroEID: 49003}, + } +} + +// infrastructureKeys are the carrier addresses the document must expose per +// chain, under the names the runtime resolves them by. +var infrastructureKeys = []string{ + "mailbox", + "merkleTreeHook", + "validatorAnnounce", + "defaultIsm", + "staticMessageIdMultisigIsmFactory", + "protocolFee", + "endpoint_v2", + "send_uln_302", + "receive_uln_302", + "dvn", + "executor", + "price_feed", + "treasury", + "dvn_fee_lib", + "executor_fee_lib", + "price_feed_implementation", + "executor_implementation", +} + +// expectedChainKeys is the exact contract set of the HL application: the +// registry and gateway of every chain, the receiver of every non-source chain, +// the transition recorder of the intermediate chain, and one adapter per +// direction of each hop. +var expectedChainKeys = map[string][]string{ + "a": {"gateway", "registry", "route_hl_hop_1_out"}, + "b": { + "gateway", + "receiver", + "registry", + "route_hl_hop_1_in", + "route_hl_hop_2_out", + "transition_recorder", + }, + "c": {"gateway", "receiver", "registry", "route_hl_hop_2_in", "transition_recorder"}, +} + +// TestDeployHLRouteApplication deploys both carrier stacks and the HL route +// application on three disposable anvil chains, then checks the result against +// what `multihop_deployer.py` writes and `multihop_preflight.py` accepts: the +// registry root and profile bindings, the bidirectional peer bindings, the +// carrier configuration, and the absence of accepted evidence before any hop +// has run. +func TestDeployHLRouteApplication(t *testing.T) { + anvilPath := lab.AnvilPath("") + if anvilPath == "" { + t.Skip("anvil binary is unavailable: foundry is not installed and anvil is not on PATH") + } + repository := repositoryRoot(t) + protocolRoot := requireProtocolArtifacts(t, repository) + vectors := fixtureKeys(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + deployerKey, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate deployer key: %v", err) + } + deployer := crypto.PubkeyToAddress(deployerKey.PublicKey) + deployerKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(deployerKey)) + role := deploy.RoleAddresses{ + Runner: keyAddress(t, vectors["root_signer"]), + RootSigner: keyAddress(t, vectors["validator"]), + LayerZeroWorker: keyAddress(t, vectors["dvn_signer"]), + HyperlaneValidator: keyAddress(t, vectors["validator"]), + HyperlaneRelayer: common.HexToAddress("0x0000000000000000000000000000000000001234"), + } + if role.Runner == role.RootSigner { + t.Fatal("the test needs a runner address distinct from the root signer") + } + + specs := hlRouteSpecs() + chainSpecs := make([]lab.Spec, 0, len(specs)) + for _, spec := range specs { + chainSpecs = append(chainSpecs, lab.Spec{ + Role: spec.Role, + ChainID: spec.ChainID, + Fund: []lab.FundRequest{{Address: deployer.Hex(), Wei: "0x21e19e0c9bab2400000"}}, + }) + } + chains, err := lab.StartChains(ctx, chainSpecs, lab.Options{Dir: t.TempDir(), AnvilPath: anvilPath}) + if err != nil { + t.Fatalf("start anvil chains: %v", err) + } + defer lab.StopChains(chains) + if len(chains) != len(specs) { + t.Fatalf("started %d chains, want %d", len(chains), len(specs)) + } + for index := range specs { + specs[index].RPCEndpoint = chains[index].URL + } + + runtimeRoot := t.TempDir() + outputPath := filepath.Join(t.TempDir(), "deployment.json") + options := deploy.Options{ + ArtifactsRoot: filepath.Join(repository, "contracts", "out"), + ProtocolArtifactsRoot: protocolRoot, + RuntimeRoot: runtimeRoot, + DeployerKey: deployerKeyHex, + OutputPath: outputPath, + Routes: []string{"HL"}, + RoleAddresses: role, + } + document, err := deploy.Deploy(ctx, specs, options) + if err != nil { + t.Fatalf("deploy: %v", err) + } + + harness := newHarness(t, ctx, specs, document, repository, runtimeRoot, outputPath, deployer, role) + harness.checkDocument() + harness.checkSecrets() + harness.checkRegistries() + harness.checkAdapters() + harness.checkHyperlane() + harness.checkLayerZero() + harness.checkRestart(specs, options) +} + +// TestDeployRejectsInvalidRequests covers the requests a deployment must refuse +// before it dials or signs anything. +func TestDeployRejectsInvalidRequests(t *testing.T) { + vectors := fixtureKeys(t) + valid := deploy.ChainSpec{Role: "a", ChainID: 3133701, RPCEndpoint: "http://127.0.0.1:1", HyperlaneDomain: 3133701, LayerZeroEID: 49001} + second := deploy.ChainSpec{Role: "b", ChainID: 3133702, RPCEndpoint: "http://127.0.0.1:1", HyperlaneDomain: 3133702, LayerZeroEID: 49002} + // Empty artifact roots keep this table independent of any forge output: + // every case below is refused before an artifact is read, and the two cases + // that are about artifacts point at paths that do not exist. + artifactsRoot := t.TempDir() + protocolRoot := t.TempDir() + base := deploy.Options{ + ArtifactsRoot: artifactsRoot, + ProtocolArtifactsRoot: protocolRoot, + RuntimeRoot: t.TempDir(), + DeployerKey: "0x2222222222222222222222222222222222222222222222222222222222222222", + OutputPath: filepath.Join(t.TempDir(), "deployment.json"), + // One hop, which the two chains below can carry; the route cases change + // it to what they are about. + Routes: []string{"H"}, + RoleAddresses: deploy.RoleAddresses{ + Runner: keyAddress(t, vectors["root_signer"]), + RootSigner: keyAddress(t, vectors["validator"]), + LayerZeroWorker: keyAddress(t, vectors["dvn_signer"]), + HyperlaneValidator: keyAddress(t, vectors["validator"]), + }, + } + for _, testCase := range []struct { + name string + chains []deploy.ChainSpec + mutate func(*deploy.Options) + message string + }{ + {"one chain", []deploy.ChainSpec{valid}, nil, "between 2 and 5 chains"}, + { + "non-contiguous roles", + []deploy.ChainSpec{valid, {Role: "c", ChainID: 3, RPCEndpoint: "http://127.0.0.1:1"}}, + nil, + `want "b"`, + }, + { + "duplicate chain id", + []deploy.ChainSpec{valid, {Role: "b", ChainID: valid.ChainID, RPCEndpoint: "http://127.0.0.1:1"}}, + nil, + "share chain id", + }, + {"route longer than the chains", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.Routes = []string{"HLH"} }, "needs 4 chains"}, + {"unregistered route", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.Routes = []string{"HHHHHH"} }, "not preregistered"}, + {"no routes", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.Routes = nil }, "no routes were requested"}, + {"runner is the root signer", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.RootSigner = options.Runner }, "must be distinct"}, + {"no runner", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.Runner = common.Address{} }, "runner address is not set"}, + {"no layerzero worker", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.LayerZeroWorker = common.Address{} }, "worker address is not set"}, + {"no validator", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.HyperlaneValidator = common.Address{} }, "validator address is not set"}, + {"invalid deployer key", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { options.DeployerKey = "0xzz" }, "invalid deployer key"}, + // The artifact cases come last because a request is always checked + // before the artifact trees are resolved. + {"missing artifacts root", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { + options.ArtifactsRoot = filepath.Join(artifactsRoot, "missing") + }, + "not a directory"}, + {"missing protocol artifacts", []deploy.ChainSpec{valid, second}, + func(options *deploy.Options) { + options.ProtocolArtifactsRoot = filepath.Join(protocolRoot, "absent") + }, + "are missing"}, + } { + t.Run(testCase.name, func(t *testing.T) { + options := base + if testCase.mutate != nil { + testCase.mutate(&options) + } + // The runtime root is shared with the table above, so give every + // case its own private tree. + options.RuntimeRoot = t.TempDir() + _, err := deploy.Deploy(context.Background(), testCase.chains, options) + if err == nil { + t.Fatalf("invalid request was accepted") + } + if !strings.Contains(err.Error(), testCase.message) { + t.Fatalf("error = %q, want it to mention %q", err, testCase.message) + } + }) + } +} + +// harness reads the deployed contracts back through the same clients the +// deployment wrote through. +type harness struct { + t *testing.T + ctx context.Context + specs []deploy.ChainSpec + document deploy.Document + repository string + runtimeRoot string + outputPath string + deployer common.Address + role deploy.RoleAddresses + clients map[string]*evm.Client + cache map[string]artifacts.Artifact +} + +func newHarness( + t *testing.T, + ctx context.Context, + specs []deploy.ChainSpec, + document deploy.Document, + repository, runtimeRoot, outputPath string, + deployer common.Address, + role deploy.RoleAddresses, +) *harness { + t.Helper() + clients := map[string]*evm.Client{} + for _, spec := range specs { + client, err := evm.Dial(ctx, spec.RPCEndpoint, 30*time.Second) + if err != nil { + t.Fatalf("dial chain %s: %v", spec.Role, err) + } + clients[spec.Role] = client + } + return &harness{ + t: t, + ctx: ctx, + specs: specs, + document: document, + repository: repository, + runtimeRoot: runtimeRoot, + outputPath: outputPath, + deployer: deployer, + role: role, + clients: clients, + cache: map[string]artifacts.Artifact{}, + } +} + +func (h *harness) contract(contract string) artifacts.Artifact { + h.t.Helper() + if cached, ok := h.cache["app:"+contract]; ok { + return cached + } + artifact, err := artifacts.Load(filepath.Join(h.repository, "contracts", "out"), contract) + if err != nil { + h.t.Fatalf("load %s: %v", contract, err) + } + h.cache["app:"+contract] = artifact + return artifact +} + +func (h *harness) protocolArtifact(project, sourceFile, contract string) artifacts.Artifact { + h.t.Helper() + key := project + ":" + contract + if cached, ok := h.cache[key]; ok { + return cached + } + artifact, err := artifacts.LoadFrom( + filepath.Join(h.repository, "protocol-projects", project, "out"), sourceFile, contract, + ) + if err != nil { + h.t.Fatalf("load %s: %v", contract, err) + } + h.cache[key] = artifact + return artifact +} + +// application resolves one deployed application contract by chain role and key. +func (h *harness) application(role, key string) common.Address { + h.t.Helper() + chain, ok := h.document.Chains[role] + if !ok { + h.t.Fatalf("the document has no chain %q", role) + } + value, ok := chain[key] + if !ok { + h.t.Fatalf("chain %s has no contract %q", role, key) + } + return common.HexToAddress(value) +} + +// infrastructure resolves one carrier contract by chain role and key. +func (h *harness) infrastructure(role, key string) common.Address { + h.t.Helper() + chain, ok := h.document.Infrastructure[role] + if !ok { + h.t.Fatalf("the document has no infrastructure for chain %q", role) + } + value, ok := chain[key] + if !ok { + h.t.Fatalf("chain %s has no infrastructure %q", role, key) + } + return common.HexToAddress(value) +} + +// read performs one eth_call and decodes its outputs. +func (h *harness) read( + role string, + artifact artifacts.Artifact, + to common.Address, + method string, + args []any, + outputs ...any, +) { + h.t.Helper() + data, err := artifact.ABI.PackCall(method, args...) + if err != nil { + h.t.Fatalf("pack %s.%s: %v", artifact.Name, method, err) + } + out, err := h.clients[role].CallContract(h.ctx, to, data) + if err != nil { + h.t.Fatalf("call %s.%s at %s: %v", artifact.Name, method, to, err) + } + if err := artifact.ABI.UnpackOutputs(method, out, outputs...); err != nil { + h.t.Fatalf("unpack %s.%s: %v", artifact.Name, method, err) + } +} + +func (h *harness) checkDocument() { + h.t.Helper() + document := h.document + if document.SchemaVersion != "xir-lab-native-multihop-deployment-v1" { + h.t.Errorf("schema version = %q", document.SchemaVersion) + } + if document.Namespace != "native-multihop-switching-v1" { + h.t.Errorf("namespace = %q", document.Namespace) + } + if document.RegistryVersion != 1 { + h.t.Errorf("registry version = %d, want 1", document.RegistryVersion) + } + if document.Deployer != strings.ToLower(h.deployer.Hex()) { + h.t.Errorf("deployer = %q, want %q", document.Deployer, h.deployer.Hex()) + } + if document.Runner != strings.ToLower(h.role.Runner.Hex()) || + document.RootSigner != strings.ToLower(h.role.RootSigner.Hex()) { + h.t.Errorf("document roles = %+v", document.RoleAddresses) + } + for _, spec := range h.specs { + chain, ok := document.Chains[spec.Role] + if !ok { + h.t.Fatalf("the document has no chain %q", spec.Role) + } + if keys := sortedKeys(chain); !slices.Equal(keys, expectedChainKeys[spec.Role]) { + h.t.Errorf("chain %s contracts = %v, want %v", spec.Role, keys, expectedChainKeys[spec.Role]) + } + infrastructure := document.Infrastructure[spec.Role] + expected := slices.Clone(infrastructureKeys) + slices.Sort(expected) + if keys := sortedKeys(infrastructure); !slices.Equal(keys, expected) { + h.t.Errorf("chain %s infrastructure = %v, want %v", spec.Role, keys, expected) + } + for key, value := range infrastructure { + if !common.IsHexAddress(value) { + h.t.Errorf("chain %s infrastructure %s = %q", spec.Role, key, value) + } + } + for key, value := range chain { + if !common.IsHexAddress(value) { + h.t.Errorf("chain %s contract %s = %q", spec.Role, key, value) + } + } + } + if len(document.Routes) != 1 { + h.t.Fatalf("routes = %v, want only HL", sortedKeys(document.Routes)) + } + route, ok := document.Routes["HL"] + if !ok { + h.t.Fatal("the document has no HL route") + } + if route.DestinationChain != "C" { + h.t.Errorf("route destination = %q, want C", route.DestinationChain) + } + if route.Receiver != strings.ToLower(document.Chains["c"]["receiver"]) { + h.t.Errorf("route receiver = %q, want the C receiver", route.Receiver) + } + if len(route.Hops) != 2 { + h.t.Fatalf("route hops = %d, want 2", len(route.Hops)) + } + for index, want := range []deploy.RouteHopDocument{ + { + HopIndex: 1, + Protocol: "H", + SourceChain: "A", + DestinationChain: "B", + OutboundAdapter: strings.ToLower(document.Chains["a"]["route_hl_hop_1_out"]), + InboundAdapter: strings.ToLower(document.Chains["b"]["route_hl_hop_1_in"]), + }, + { + HopIndex: 2, + Protocol: "L", + SourceChain: "B", + DestinationChain: "C", + OutboundAdapter: strings.ToLower(document.Chains["b"]["route_hl_hop_2_out"]), + InboundAdapter: strings.ToLower(document.Chains["c"]["route_hl_hop_2_in"]), + }, + } { + hop := route.Hops[index] + if hop.HopIndex != want.HopIndex || hop.Protocol != want.Protocol || + hop.SourceChain != want.SourceChain || hop.DestinationChain != want.DestinationChain || + hop.OutboundAdapter != want.OutboundAdapter || hop.InboundAdapter != want.InboundAdapter { + h.t.Errorf("hop %d = %+v, want %+v", index+1, hop, want) + } + profileHash, err := xir.ProfileHash("HL", index+1) + if err != nil { + h.t.Fatalf("profile hash: %v", err) + } + if hop.ProfileHash != xir.FormatDigest(profileHash) { + h.t.Errorf( + "hop %d profile hash = %s, want %s", index+1, hop.ProfileHash, xir.FormatDigest(profileHash), + ) + } + } + for _, spec := range h.specs { + identifier, err := xir.GatewayTypedID(spec.ChainID) + if err != nil { + h.t.Fatalf("gateway typed id: %v", err) + } + hash, err := identifier.Hash() + if err != nil { + h.t.Fatalf("gateway typed id hash: %v", err) + } + entry, ok := document.GatewayTypedIDs[strings.ToUpper(spec.Role)] + if !ok { + h.t.Fatalf("the document has no gateway typed id for %s", spec.Role) + } + if entry.Hash != xir.FormatDigest(hash) { + h.t.Errorf("chain %s gateway hash = %s, want %s", spec.Role, entry.Hash, xir.FormatDigest(hash)) + } + if entry.Value != "0x"+hex.EncodeToString(identifier.Value) || entry.Kind != xir.KindEVM { + h.t.Errorf("chain %s gateway id = %+v", spec.Role, entry) + } + } + + // The document on disk is what its digest covers, the digest of the + // journal is the digest of the journal the deployment wrote, and the + // journal holds exactly one row per transaction. + payload, err := os.ReadFile(h.outputPath) + if err != nil { + h.t.Fatalf("read deployment document: %v", err) + } + digest := sha256.Sum256(payload) + if document.DeploymentSHA256 != hex.EncodeToString(digest[:]) { + h.t.Errorf( + "deployment digest = %q, want %q", document.DeploymentSHA256, hex.EncodeToString(digest[:]), + ) + } + var onDisk map[string]any + if err := json.Unmarshal(payload, &onDisk); err != nil { + h.t.Fatalf("decode deployment document: %v", err) + } + if _, present := onDisk["deployment_sha256"]; present { + h.t.Error("the document on disk contains its own digest") + } + journal, err := os.ReadFile(filepath.Join(h.runtimeRoot, "deploy", "deployment-journal.jsonl")) + if err != nil { + h.t.Fatalf("read journal: %v", err) + } + journalDigest := sha256.Sum256(journal) + if document.JournalSHA256 != hex.EncodeToString(journalDigest[:]) { + h.t.Errorf( + "journal digest = %q, want %q", document.JournalSHA256, hex.EncodeToString(journalDigest[:]), + ) + } + rows := strings.Split(strings.TrimSpace(string(journal)), "\n") + transactions := document.DeploymentGas.TransactionCount + document.ProtocolGas.TransactionCount + if len(rows) != transactions { + h.t.Errorf("journal has %d rows, want %d transactions", len(rows), transactions) + } + + // The gas accounting classifies the application actions only, and every + // receipt carries the provenance the Python document records. + gas := document.DeploymentGas + if gas.TransactionCount == 0 || gas.GasUsed == 0 { + h.t.Fatalf("deployment gas = %+v, want a non-empty accounting", gas) + } + if len(gas.Receipts) != gas.TransactionCount { + h.t.Errorf("gas receipts = %d, want %d", len(gas.Receipts), gas.TransactionCount) + } + classifications := map[string]bool{} + for _, entry := range gas.ClassifiedTotals { + classifications[entry.Classification] = true + } + for _, want := range []string{ + "xir_only_contract_deployment", + "xir_profile_root_peer_binding_initialization", + } { + if !classifications[want] { + h.t.Errorf("gas classification %q is missing", want) + } + } + deployed, configured := 0, 0 + for _, receipt := range gas.Receipts { + if receipt.GasUsed == 0 || receipt.BlockNumber == 0 { + h.t.Errorf("receipt %s has no gas or block", receipt.Action) + } + if len(receipt.CalldataSHA256) != 64 || len(receipt.RuntimeCodeSHA256) != 64 { + h.t.Errorf("receipt %s lacks digests: %+v", receipt.Action, receipt) + } + if len(receipt.ArtifactSHA256) != 64 { + h.t.Errorf("receipt %s has no artifact digest", receipt.Action) + } + if !common.IsHexAddress(receipt.Target) { + h.t.Errorf("receipt %s target = %q", receipt.Action, receipt.Target) + } + switch receipt.Classification { + case "xir_only_contract_deployment": + deployed++ + case "xir_profile_root_peer_binding_initialization": + configured++ + default: + h.t.Errorf("receipt %s has classification %q", receipt.Action, receipt.Classification) + } + } + // Three registries, three gateways, two receivers, two transition + // recorders, four adapters. + if want := 3 + 3 + 2 + 2 + 4; deployed != want { + h.t.Errorf("classified deployments = %d, want %d", deployed, want) + } + // Four peer bindings and two enforced-options calls, one prior verifier + // binding, three registry profiles and two roots. + if want := 4 + 2 + 1 + 3 + 2; configured != want { + h.t.Errorf("classified configuration calls = %d, want %d", configured, want) + } + if document.ProtocolGas.TransactionCount == 0 { + h.t.Error("the protocol bootstrap has no gas accounting") + } + for _, spec := range h.specs { + entry, ok := document.ProtocolDeployments[spec.Role] + if !ok { + h.t.Fatalf("chain %s has no protocol deployment document", spec.Role) + } + if len(entry.Hyperlane.Contracts) != 6 || len(entry.LayerZero.Contracts) != 11 { + h.t.Errorf( + "chain %s carrier documents have %d and %d contracts", + spec.Role, len(entry.Hyperlane.Contracts), len(entry.LayerZero.Contracts), + ) + } + if entry.Hyperlane.LocalDomain != spec.HyperlaneDomain || entry.LayerZero.LocalEID != spec.LayerZeroEID { + h.t.Errorf("chain %s carrier coordinates = %+v", spec.Role, entry) + } + if entry.ChainID != spec.ChainID { + h.t.Errorf("chain %s carrier chain id = %d, want %d", spec.Role, entry.ChainID, spec.ChainID) + } + gas, ok := document.ProtocolGas.ByChain[spec.Role] + if !ok || gas.TransactionCount == 0 { + h.t.Errorf("chain %s has no protocol gas accounting", spec.Role) + } + // Seven Hyperlane actions (Mailbox, ISM factory, ISM, MerkleTreeHook, + // ProtocolFee, ValidatorAnnounce, initialize) and twenty-seven + // LayerZero actions, exactly as the two forge scripts broadcast. + if want := 7 + 27; gas.TransactionCount != want { + h.t.Errorf("chain %s protocol transactions = %d, want %d", spec.Role, gas.TransactionCount, want) + } + } +} + +// checkSecrets proves the deployment never echoed a signing key into the +// document, the journal, or the carrier documents. +func (h *harness) checkSecrets() { + h.t.Helper() + secrets := []string{} + for _, key := range fixtureKeys(h.t) { + secrets = append(secrets, strings.ToLower(strings.TrimPrefix(key, "0x"))) + } + check := func(path string) { + payload, err := os.ReadFile(path) + if err != nil { + h.t.Fatalf("read %s: %v", path, err) + } + text := strings.ToLower(string(payload)) + for _, secret := range secrets { + if strings.Contains(text, secret) { + h.t.Errorf("%s contains a fixture private key", path) + } + } + } + check(h.outputPath) + check(filepath.Join(h.runtimeRoot, "deploy", "deployment-journal.jsonl")) + err := filepath.Walk( + filepath.Join(h.runtimeRoot, "hyperlane"), + func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + check(path) + return nil + }, + ) + if err != nil { + h.t.Fatalf("walk carrier documents: %v", err) + } +} + +func (h *harness) checkRegistries() { + h.t.Helper() + registry := h.contract("XIRRegistry") + sourceHash := h.gatewayHash(0) + for _, role := range []string{"b", "c"} { + var root rootSnapshotEnvelope + h.read(role, registry, h.application(role, "registry"), "rootAt", []any{uint32(1)}, &root) + if root.Snapshot.GatewayHash != sourceHash { + h.t.Errorf("chain %s root gateway hash = %x, want %x", role, root.Snapshot.GatewayHash, sourceHash) + } + if root.Snapshot.Signer != h.role.RootSigner { + h.t.Errorf("chain %s root signer = %s, want %s", role, root.Snapshot.Signer, h.role.RootSigner) + } + if !root.Snapshot.Enabled || root.Snapshot.ValidAfter != 0 || root.Snapshot.ValidUntil != 0 { + h.t.Errorf("chain %s root = %+v, want an enabled, unbounded root", role, root.Snapshot) + } + } + // B completes hop 1, so its profile points at its inbound Hyperlane + // adapter; C completes both hops, so both of its profiles point at its + // inbound LayerZero adapter. + for _, want := range []struct { + role string + hop int + adapter string + }{ + {"b", 1, "route_hl_hop_1_in"}, + {"c", 1, "route_hl_hop_2_in"}, + {"c", 2, "route_hl_hop_2_in"}, + } { + profileHash, err := xir.ProfileHash("HL", want.hop) + if err != nil { + h.t.Fatalf("profile hash: %v", err) + } + var profile profileSnapshotEnvelope + h.read( + want.role, registry, h.application(want.role, "registry"), + "profileAt", []any{profileHash}, &profile, + ) + address := h.application(want.role, want.adapter) + if profile.Snapshot.Adapter != address { + h.t.Errorf( + "chain %s profile %d adapter = %s, want %s", + want.role, want.hop, profile.Snapshot.Adapter, address, + ) + } + if profile.Snapshot.SecurityLevel != 1 || !profile.Snapshot.Enabled { + h.t.Errorf("chain %s profile %d = %+v", want.role, want.hop, profile.Snapshot) + } + if profile.Snapshot.SourceHash != h.gatewayHash(want.hop-1) || + profile.Snapshot.DestinationHash != h.gatewayHash(want.hop) { + h.t.Errorf( + "chain %s profile %d gateway bindings = %x/%x", + want.role, want.hop, profile.Snapshot.SourceHash, profile.Snapshot.DestinationHash, + ) + } + } +} + +// checkAdapters verifies the bidirectional peer bindings of the deployed route, +// the enforced LayerZero options, the authority bindings, and that no adapter +// has accepted evidence before any hop has run. +func (h *harness) checkAdapters() { + h.t.Helper() + hyperlane := h.contract("HyperlaneAdapter") + layerZero := h.contract("LayerZeroAdapter") + adapters := map[string]struct{ role, key, contract string }{} + for _, hop := range h.document.Routes["HL"].Hops { + sourceRole := strings.ToLower(hop.SourceChain) + destinationRole := strings.ToLower(hop.DestinationChain) + sourceKey := h.keyByAddress(sourceRole, hop.OutboundAdapter) + destinationKey := h.keyByAddress(destinationRole, hop.InboundAdapter) + sourceAddress := h.application(sourceRole, sourceKey) + destinationAddress := h.application(destinationRole, destinationKey) + if hop.Protocol == "H" { + var remote [32]byte + var expected [32]byte + copy(expected[12:], destinationAddress.Bytes()) + h.read(sourceRole, hyperlane, sourceAddress, "remoteAdapter", nil, &remote) + if remote != expected { + h.t.Errorf("%s remote adapter = %x, want %x", sourceKey, remote, expected) + } + copy(expected[12:], sourceAddress.Bytes()) + h.read(destinationRole, hyperlane, destinationAddress, "remoteAdapter", nil, &remote) + if remote != expected { + h.t.Errorf("%s remote adapter = %x, want %x", destinationKey, remote, expected) + } + for _, side := range []struct{ role, key string }{ + {sourceRole, sourceKey}, {destinationRole, destinationKey}, + } { + var mailbox common.Address + h.read(side.role, hyperlane, h.application(side.role, side.key), "mailbox", nil, &mailbox) + if mailbox != h.infrastructure(side.role, "mailbox") { + h.t.Errorf("%s mailbox = %s", side.key, mailbox) + } + } + adapters[sourceKey] = struct{ role, key, contract string }{sourceRole, sourceKey, "HyperlaneAdapter"} + adapters[destinationKey] = struct{ role, key, contract string }{ + destinationRole, destinationKey, "HyperlaneAdapter", + } + continue + } + expectedOptions := crypto.Keccak256Hash(layerZeroReceiveOptions(1_500_000)) + for _, side := range []struct { + role, key string + address common.Address + remoteEID uint32 + expectedPeer [32]byte + }{ + {sourceRole, sourceKey, sourceAddress, h.specByRole(destinationRole).LayerZeroEID, bytes32Address(destinationAddress)}, + {destinationRole, destinationKey, destinationAddress, h.specByRole(sourceRole).LayerZeroEID, bytes32Address(sourceAddress)}, + } { + var peer [32]byte + h.read(side.role, layerZero, side.address, "remotePeer", nil, &peer) + if peer != side.expectedPeer { + h.t.Errorf("%s remote peer = %x, want %x", side.key, peer, side.expectedPeer) + } + var remoteEID uint32 + h.read(side.role, layerZero, side.address, "remoteEid", nil, &remoteEID) + if remoteEID != side.remoteEID { + h.t.Errorf("%s remote eid = %d, want %d", side.key, remoteEID, side.remoteEID) + } + var options [32]byte + h.read(side.role, layerZero, side.address, "enforcedOptionsHash", nil, &options) + if options != expectedOptions { + h.t.Errorf("%s enforced options = %x, want %x", side.key, options, expectedOptions) + } + var endpoint common.Address + h.read(side.role, layerZero, side.address, "endpoint", nil, &endpoint) + if endpoint != h.infrastructure(side.role, "endpoint_v2") { + h.t.Errorf("%s endpoint = %s", side.key, endpoint) + } + adapters[side.key] = struct{ role, key, contract string }{ + side.role, side.key, "LayerZeroAdapter", + } + } + } + if len(adapters) != 4 { + h.t.Fatalf("the route wired %d adapters, want 4", len(adapters)) + } + // The authority bindings every adapter gates dispatch on. + for _, adapter := range adapters { + artifact := h.contract(adapter.contract) + address := h.application(adapter.role, adapter.key) + var administrator, runner common.Address + h.read(adapter.role, artifact, address, "administrator", nil, &administrator) + h.read(adapter.role, artifact, address, "runner", nil, &runner) + if administrator != h.deployer { + h.t.Errorf("%s administrator = %s, want the deployer", adapter.key, administrator) + } + if runner != h.role.Runner { + h.t.Errorf("%s runner = %s, want %s", adapter.key, runner, h.role.Runner) + } + } + // No adapter has accepted evidence: verify is false for zero digests and + // for arbitrary digests, whether or not they look like a real hop. + for _, adapter := range adapters { + artifact := h.contract(adapter.contract) + address := h.application(adapter.role, adapter.key) + profileHash, err := xir.ProfileHash("HL", 1) + if err != nil { + h.t.Fatalf("profile hash: %v", err) + } + for _, digest := range [][3][32]byte{ + {}, + {{0x01}, {0x02}, {0x03}}, + {profileHash, {0x02}, {0x03}}, + } { + var accepted bool + h.read( + adapter.role, artifact, address, "verify", + []any{digest[0], digest[1], digest[2]}, &accepted, + ) + if accepted { + h.t.Errorf("%s accepted evidence %x", adapter.key, digest) + } + } + } +} + +// checkHyperlane verifies the Mailbox initialization, the ISM the factory +// deployed for the configured validator, and the registry entry the Hyperlane +// agents read. +func (h *harness) checkHyperlane() { + h.t.Helper() + mailbox := h.protocolArtifact("hyperlane-native", "Mailbox.sol", "Mailbox") + ism := h.protocolArtifact("hyperlane-native", "StaticMultisigIsm.sol", "StaticMessageIdMultisigIsm") + for _, spec := range h.specs { + address := h.infrastructure(spec.Role, "mailbox") + var domain uint32 + var owner, defaultIsm, defaultHook, requiredHook common.Address + h.read(spec.Role, mailbox, address, "localDomain", nil, &domain) + h.read(spec.Role, mailbox, address, "owner", nil, &owner) + h.read(spec.Role, mailbox, address, "defaultIsm", nil, &defaultIsm) + h.read(spec.Role, mailbox, address, "defaultHook", nil, &defaultHook) + h.read(spec.Role, mailbox, address, "requiredHook", nil, &requiredHook) + if domain != spec.HyperlaneDomain { + h.t.Errorf("chain %s mailbox domain = %d, want %d", spec.Role, domain, spec.HyperlaneDomain) + } + if owner != h.deployer { + h.t.Errorf("chain %s mailbox owner = %s, want the deployer", spec.Role, owner) + } + if defaultIsm != h.infrastructure(spec.Role, "defaultIsm") { + h.t.Errorf("chain %s default ISM = %s", spec.Role, defaultIsm) + } + if defaultHook != h.infrastructure(spec.Role, "protocolFee") { + h.t.Errorf("chain %s default hook = %s, want the protocol fee hook", spec.Role, defaultHook) + } + if requiredHook != h.infrastructure(spec.Role, "merkleTreeHook") { + h.t.Errorf("chain %s required hook = %s, want the merkle tree hook", spec.Role, requiredHook) + } + + var validators []common.Address + var threshold uint8 + h.read( + spec.Role, ism, h.infrastructure(spec.Role, "defaultIsm"), + "validatorsAndThreshold", []any{[]byte{}}, &validators, &threshold, + ) + if len(validators) != 1 || validators[0] != h.role.HyperlaneValidator { + h.t.Errorf("chain %s ISM validators = %v, want the configured validator", spec.Role, validators) + } + if threshold != 1 { + h.t.Errorf("chain %s ISM threshold = %d, want 1", spec.Role, threshold) + } + + payload, err := os.ReadFile(filepath.Join( + h.runtimeRoot, "hyperlane", "registry", "chains", "xirlocalchain"+spec.Role, "addresses.yaml", + )) + if err != nil { + h.t.Fatalf("read Hyperlane registry entry: %v", err) + } + for _, field := range []string{ + "mailbox", + "validatorAnnounce", + "merkleTreeHook", + "interchainGasPaymaster", + "defaultIsm", + "staticMessageIdMultisigIsmFactory", + "protocolFee", + } { + if !strings.Contains(string(payload), field+":") { + h.t.Errorf("chain %s registry entry has no %s", spec.Role, field) + } + } + } +} + +// checkLayerZero verifies the endpoint, the registered libraries, the default +// send and receive libraries of every remote chain, and the DVN signer set. +func (h *harness) checkLayerZero() { + h.t.Helper() + endpoint := h.protocolArtifact("layerzero-native", "EndpointV2.sol", "EndpointV2") + dvn := h.protocolArtifact("layerzero-native", "DVN.sol", "DVN") + priceFeed := h.protocolArtifact("layerzero-native", "PriceFeed.sol", "PriceFeed") + for _, spec := range h.specs { + address := h.infrastructure(spec.Role, "endpoint_v2") + var eid uint32 + h.read(spec.Role, endpoint, address, "eid", nil, &eid) + if eid != spec.LayerZeroEID { + h.t.Errorf("chain %s endpoint eid = %d, want %d", spec.Role, eid, spec.LayerZeroEID) + } + for _, library := range []struct{ key, product string }{ + {"send_uln_302", "defaultSendLibrary"}, + {"receive_uln_302", "defaultReceiveLibrary"}, + } { + expected := h.infrastructure(spec.Role, library.key) + for _, remote := range h.remoteEIDs(spec.Role) { + var registered common.Address + h.read(spec.Role, endpoint, address, library.product, []any{remote}, ®istered) + if registered != expected { + h.t.Errorf( + "chain %s %s(%d) = %s, want %s", + spec.Role, library.product, remote, registered, expected, + ) + } + } + var registered bool + h.read(spec.Role, endpoint, address, "isRegisteredLibrary", []any{expected}, ®istered) + if !registered { + h.t.Errorf("chain %s library %s is not registered", spec.Role, library.key) + } + } + var signers []common.Address + var quorum uint64 + h.read(spec.Role, dvn, h.infrastructure(spec.Role, "dvn"), "getSigners", nil, &signers) + h.read(spec.Role, dvn, h.infrastructure(spec.Role, "dvn"), "quorum", nil, &quorum) + if len(signers) != 1 || signers[0] != h.role.LayerZeroWorker { + h.t.Errorf("chain %s DVN signers = %v, want the LayerZero worker", spec.Role, signers) + } + if quorum != 1 { + h.t.Errorf("chain %s DVN quorum = %d, want 1", spec.Role, quorum) + } + // The price feed is behind an ERC1967 proxy and must answer with the + // price the script configured for every remote chain. + for _, remote := range h.remoteEIDs(spec.Role) { + var price priceEnvelope + h.read( + spec.Role, priceFeed, h.infrastructure(spec.Role, "price_feed"), + "getPrice", []any{uint32(remote)}, &price, + ) + nativePrice := new(big.Int).Exp(big.NewInt(10), big.NewInt(20), nil) + if price.Price.PriceRatio.Cmp(nativePrice) != 0 || price.Price.GasPriceInUnit != 1 || + price.Price.GasPerByte != 1 { + h.t.Errorf("chain %s price feed %d = %+v, want 1e20/1/1", spec.Role, remote, price.Price) + } + } + } +} + +// checkRestart re-runs the same deployment against the same runtime root and +// proves that nothing was broadcast twice: no account nonce moved, the journal +// did not grow, and the second document is byte for byte the first one. +func (h *harness) checkRestart(specs []deploy.ChainSpec, options deploy.Options) { + h.t.Helper() + nonces := map[string]uint64{} + for _, spec := range h.specs { + nonce, err := h.clients[spec.Role].PendingNonce(h.ctx, h.deployer) + if err != nil { + h.t.Fatalf("read pending nonce of chain %s: %v", spec.Role, err) + } + nonces[spec.Role] = nonce + } + journalPath := filepath.Join(h.runtimeRoot, "deploy", "deployment-journal.jsonl") + journalBefore, err := os.ReadFile(journalPath) + if err != nil { + h.t.Fatalf("read journal: %v", err) + } + first, err := os.ReadFile(h.outputPath) + if err != nil { + h.t.Fatalf("read deployment document: %v", err) + } + + restartOptions := options + restartOptions.OutputPath = filepath.Join(h.t.TempDir(), "restart.json") + second, err := deploy.Deploy(h.ctx, specs, restartOptions) + if err != nil { + h.t.Fatalf("restart deploy: %v", err) + } + for _, spec := range h.specs { + nonce, err := h.clients[spec.Role].PendingNonce(h.ctx, h.deployer) + if err != nil { + h.t.Fatalf("read pending nonce of chain %s: %v", spec.Role, err) + } + if nonce != nonces[spec.Role] { + h.t.Errorf( + "the restart sent transactions on chain %s: nonce %d became %d", + spec.Role, nonces[spec.Role], nonce, + ) + } + } + journalAfter, err := os.ReadFile(journalPath) + if err != nil { + h.t.Fatalf("read journal: %v", err) + } + if !slices.Equal(journalBefore, journalAfter) { + h.t.Error("the restart appended journal rows for actions it did not send") + } + if second.JournalSHA256 != h.document.JournalSHA256 { + h.t.Errorf("restart journal digest = %q, want %q", second.JournalSHA256, h.document.JournalSHA256) + } + if !reflect.DeepEqual(second.Chains, h.document.Chains) { + h.t.Errorf("restart chain manifest = %v, want %v", second.Chains, h.document.Chains) + } + if !reflect.DeepEqual(second.DeploymentGas.ClassifiedTotals, h.document.DeploymentGas.ClassifiedTotals) { + h.t.Errorf("restart gas classification = %+v, want %+v", + second.DeploymentGas.ClassifiedTotals, h.document.DeploymentGas.ClassifiedTotals) + } + payload, err := os.ReadFile(restartOptions.OutputPath) + if err != nil { + h.t.Fatalf("read restart document: %v", err) + } + if !slices.Equal(first, payload) { + h.t.Error("the restart document differs from the first deployment document") + } + if second.DeploymentSHA256 != h.document.DeploymentSHA256 { + h.t.Errorf("restart digest = %q, want %q", second.DeploymentSHA256, h.document.DeploymentSHA256) + } +} + +// keyByAddress finds the manifest key a route hop names, which proves the +// document's hop wiring refers to the contracts it deployed. +func (h *harness) keyByAddress(role, address string) string { + h.t.Helper() + for key, value := range h.document.Chains[role] { + if strings.EqualFold(value, address) { + return key + } + } + h.t.Fatalf("chain %s has no contract at %s", role, address) + return "" +} + +func (h *harness) specByRole(role string) deploy.ChainSpec { + h.t.Helper() + for _, spec := range h.specs { + if spec.Role == role { + return spec + } + } + h.t.Fatalf("no spec for role %s", role) + return deploy.ChainSpec{} +} + +func (h *harness) gatewayHash(index int) [32]byte { + h.t.Helper() + identifier, err := xir.GatewayTypedID(h.specs[index].ChainID) + if err != nil { + h.t.Fatalf("gateway typed id: %v", err) + } + hash, err := identifier.Hash() + if err != nil { + h.t.Fatalf("gateway typed id hash: %v", err) + } + return hash +} + +func (h *harness) remoteEIDs(role string) []uint32 { + out := []uint32{} + for _, spec := range h.specs { + if spec.Role != role { + out = append(out, spec.LayerZeroEID) + } + } + return out +} + +// rootSnapshotEnvelope carries the single tuple output of rootAt. +type rootSnapshotEnvelope struct { + Snapshot rootSnapshot +} + +type rootSnapshot struct { + GatewayHash [32]byte + Signer common.Address + ValidAfter uint64 + ValidUntil uint64 + Enabled bool +} + +// priceEnvelope carries the single tuple output of PriceFeed.getPrice. +type priceEnvelope struct { + Price priceTuple +} + +type priceTuple struct { + PriceRatio *big.Int + GasPriceInUnit uint64 + GasPerByte uint32 +} + +// profileSnapshotEnvelope carries the single tuple output of profileAt. +type profileSnapshotEnvelope struct { + Snapshot profileSnapshot +} + +type profileSnapshot struct { + SourceHash [32]byte + DestinationHash [32]byte + Adapter common.Address + SecurityLevel uint8 + ValidAfter uint64 + ValidUntil uint64 + Enabled bool +} + +// layerZeroReceiveOptions rebuilds the type-3 executor LZ_RECEIVE options +// independently of the deployment, so the test compares against the bytes +// `executor_lz_receive_options` builds rather than the bytes the deployment +// happened to write. +func layerZeroReceiveOptions(gasLimit uint64) []byte { + options := []byte{0x00, 0x03, 0x01, 0x00, 0x11, 0x01} + limit := make([]byte, 16) + binary.BigEndian.PutUint64(limit[8:], gasLimit) + return append(options, limit...) +} + +func bytes32Address(address common.Address) [32]byte { + var out [32]byte + copy(out[12:], address.Bytes()) + return out +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func keyAddress(t *testing.T, privateKeyHex string) common.Address { + t.Helper() + key, err := crypto.HexToECDSA(strings.TrimPrefix(privateKeyHex, "0x")) + if err != nil { + t.Fatalf("parse fixture key: %v", err) + } + return crypto.PubkeyToAddress(key.PublicKey) +} + +// fixtureKeys reads the parity vector fixture keys, the only private keys the +// repository carries. +func fixtureKeys(t *testing.T) map[string]string { + t.Helper() + payload, err := os.ReadFile(filepath.Join(repositoryRoot(t), "go-runtime", "testdata", "vectors.json")) + if err != nil { + t.Fatalf("read parity vectors: %v", err) + } + var vectors struct { + Constants struct { + FixtureKeys map[string]string `json:"fixture_keys"` + } `json:"constants"` + } + if err := json.Unmarshal(payload, &vectors); err != nil { + t.Fatalf("decode parity vectors: %v", err) + } + for _, name := range []string{"root_signer", "validator", "dvn_signer"} { + if vectors.Constants.FixtureKeys[name] == "" { + t.Fatalf("the parity vectors have no %s key", name) + } + } + return vectors.Constants.FixtureKeys +} + +// requireProtocolArtifacts returns the protocol project root, or skips when the +// carrier stacks have no forge output. CI builds `contracts/out` only, so the +// Hyperlane and LayerZero artifacts are absent there and the carrier bootstrap +// cannot be exercised; the deployment document's carrier sections are also +// derived from those artifacts, so the whole integration test is skipped rather +// than weakened. +func requireProtocolArtifacts(t *testing.T, repository string) string { + t.Helper() + root := filepath.Join(repository, "protocol-projects") + for _, stack := range []string{"hyperlane-native", "layerzero-native"} { + path := filepath.Join(root, stack, "out") + if info, err := os.Stat(path); err != nil || !info.IsDir() { + t.Skipf("protocol artifacts are absent: %s is not a directory", path) + } + } + return root +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + return root +} diff --git a/go-runtime/internal/deploy/journal.go b/go-runtime/internal/deploy/journal.go new file mode 100644 index 0000000..5885fe9 --- /dev/null +++ b/go-runtime/internal/deploy/journal.go @@ -0,0 +1,65 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// journal is the append-only JSON lines record of every action of one +// deployment, the Go counterpart of the Python deployer's +// `deployment-journal.jsonl`. The durable ledger of record is the state store +// behind the transactor; the journal is what the deployment document digests, +// so a reader can tell which rows the documented gas accounting covers. +type journal struct { + path string + file *os.File +} + +func openJournal(path string) (*journal, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("deploy: create journal directory: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("deploy: open deployment journal: %w", err) + } + return &journal{path: path, file: file}, nil +} + +// append writes one row and fsyncs it, so a deployment that dies immediately +// afterwards still has the row on disk. +func (j *journal) append(record actionRecord) error { + payload, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("deploy: encode journal row: %w", err) + } + payload = append(payload, '\n') + if _, err := j.file.Write(payload); err != nil { + return fmt.Errorf("deploy: write journal row: %w", err) + } + if err := j.file.Sync(); err != nil { + return fmt.Errorf("deploy: sync journal: %w", err) + } + return nil +} + +// digest is the sha256 of the journal as it stands on disk. +func (j *journal) digest() (string, error) { + payload, err := os.ReadFile(j.path) + if err != nil { + return "", fmt.Errorf("deploy: read deployment journal: %w", err) + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]), nil +} + +func (j *journal) close() error { + if j.file == nil { + return nil + } + return j.file.Close() +} diff --git a/go-runtime/internal/deploy/protocol.go b/go-runtime/internal/deploy/protocol.go new file mode 100644 index 0000000..7c7ffbc --- /dev/null +++ b/go-runtime/internal/deploy/protocol.go @@ -0,0 +1,566 @@ +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// Carrier bootstrap document identity, the schema versions +// `DeployHyperlaneNative.s.sol` and `DeployLayerZeroNative.s.sol` write. +const ( + hyperlaneSchemaVersion = "xir-lab-hyperlane-native-deployment-v1" + layerZeroSchemaVersion = "xir-lab-layerzero-deployment-v1" +) + +// hyperlaneProject and layerZeroProject are the protocol project directories +// inside Options.ProtocolArtifactsRoot. +const ( + hyperlaneProject = "hyperlane-native" + layerZeroProject = "layerzero-native" +) + +// HyperlaneDeploymentDocument is the Hyperlane bootstrap of one chain, with +// the keys `DeployHyperlaneNative.s.sol` writes. +type HyperlaneDeploymentDocument struct { + SchemaVersion string `json:"schema_version"` + LocalDomain uint32 `json:"local_domain"` + Contracts map[string]string `json:"contracts"` +} + +// LayerZeroDeploymentDocument is the LayerZero bootstrap of one chain, with +// the keys `DeployLayerZeroNative.s.sol` writes. +type LayerZeroDeploymentDocument struct { + SchemaVersion string `json:"schema_version"` + LocalEID uint32 `json:"local_eid"` + Contracts map[string]string `json:"contracts"` +} + +// ProtocolDeploymentDocument is the carrier bootstrap of one chain, including +// the chain coordinates the document's address maps deliberately do not carry. +// The RPC endpoint is not recorded: the document is evidence, and an endpoint +// URL may carry credentials. +type ProtocolDeploymentDocument struct { + ChainID uint64 `json:"chain_id"` + HyperlaneDomain uint32 `json:"hyperlane_domain"` + LayerZeroEID uint32 `json:"layerzero_eid"` + Hyperlane HyperlaneDeploymentDocument `json:"hyperlane"` + LayerZero LayerZeroDeploymentDocument `json:"layerzero"` +} + +// protocolStack is one chain's deployed carriers. +type protocolStack struct { + document ProtocolDeploymentDocument + + mailbox common.Address + merkleTreeHook common.Address + validatorAnnounce common.Address + defaultIsm common.Address + staticMessageIDMultisigIsmFactory common.Address + protocolFee common.Address + interchainGasPaymaster common.Address + + endpointV2 common.Address + sendUln302 common.Address + receiveUln302 common.Address + dvn common.Address + executor common.Address + priceFeed common.Address + treasury common.Address + dvnFeeLib common.Address + executorFeeLib common.Address + priceFeedImplementation common.Address + executorImplementation common.Address +} + +// infrastructureDocument merges the carrier addresses of every chain under the +// names the runtime resolves them by: the Hyperlane registry names and the +// LayerZero deployment names, so both a Go reader and a Python reader find +// what they expect. +func (r *deployment) infrastructureDocument() map[string]map[string]string { + out := make(map[string]map[string]string, len(r.order)) + for _, role := range r.order { + stack := r.stack[role] + addresses := map[string]string{} + for key, address := range stack.hyperlaneContracts() { + addresses[key] = address.Hex() + } + for key, address := range stack.layerZeroContracts() { + addresses[key] = address.Hex() + } + out[role] = addresses + } + return out +} + +// hyperlaneContracts returns the registry addresses of one chain in the key +// order `DeployHyperlaneNative.s.sol` writes. +func (s *protocolStack) hyperlaneContracts() map[string]common.Address { + return map[string]common.Address{ + "mailbox": s.mailbox, + "merkleTreeHook": s.merkleTreeHook, + "validatorAnnounce": s.validatorAnnounce, + "defaultIsm": s.defaultIsm, + "staticMessageIdMultisigIsmFactory": s.staticMessageIDMultisigIsmFactory, + "protocolFee": s.protocolFee, + } +} + +// layerZeroContracts returns the deployment addresses of one chain in the key +// order `DeployLayerZeroNative.s.sol` writes. +func (s *protocolStack) layerZeroContracts() map[string]common.Address { + return map[string]common.Address{ + "endpoint_v2": s.endpointV2, + "send_uln_302": s.sendUln302, + "receive_uln_302": s.receiveUln302, + "dvn": s.dvn, + "executor": s.executor, + "price_feed": s.priceFeed, + "treasury": s.treasury, + "dvn_fee_lib": s.dvnFeeLib, + "executor_fee_lib": s.executorFeeLib, + "price_feed_implementation": s.priceFeedImplementation, + "executor_implementation": s.executorImplementation, + } +} + +// bootstrapProtocols deploys both carriers on every chain, in chain order. +func (r *deployment) bootstrapProtocols(ctx context.Context) error { + for index, role := range r.order { + spec := r.specs[index] + stack := &protocolStack{} + if err := r.bootstrapHyperlane(ctx, role, stack); err != nil { + return err + } + if err := r.bootstrapLayerZero(ctx, role, remoteEIDs(r.specs, role), stack); err != nil { + return err + } + stack.document = ProtocolDeploymentDocument{ + ChainID: spec.ChainID, + HyperlaneDomain: spec.HyperlaneDomain, + LayerZeroEID: spec.LayerZeroEID, + Hyperlane: HyperlaneDeploymentDocument{ + SchemaVersion: hyperlaneSchemaVersion, + LocalDomain: spec.HyperlaneDomain, + Contracts: addressStrings(stack.hyperlaneContracts()), + }, + LayerZero: LayerZeroDeploymentDocument{ + SchemaVersion: layerZeroSchemaVersion, + LocalEID: spec.LayerZeroEID, + Contracts: addressStrings(stack.layerZeroContracts()), + }, + } + r.stack[role] = stack + r.protocolDocuments[role] = stack.document + if err := r.writeProtocolDocuments(role, spec, stack); err != nil { + return err + } + } + return nil +} + +// bootstrapHyperlane reproduces +// `DeployHyperlaneNative.s.sol`: Mailbox, the static multisig ISM factory and +// the ISM it deploys, MerkleTreeHook, ProtocolFee, ValidatorAnnounce, then +// `initialize`. +func (r *deployment) bootstrapHyperlane(ctx context.Context, role string, stack *protocolStack) error { + chain := r.chains[role] + spec := chain.spec + mailboxArtifact, err := r.loadHyperlane("Mailbox.sol", "Mailbox") + if err != nil { + return err + } + factoryArtifact, err := r.loadHyperlane("StaticMultisigIsm.sol", "StaticMessageIdMultisigIsmFactory") + if err != nil { + return err + } + hookArtifact, err := r.loadHyperlane("MerkleTreeHook.sol", "MerkleTreeHook") + if err != nil { + return err + } + feeArtifact, err := r.loadHyperlane("ProtocolFee.sol", "ProtocolFee") + if err != nil { + return err + } + announceArtifact, err := r.loadHyperlane("ValidatorAnnounce.sol", "ValidatorAnnounce") + if err != nil { + return err + } + + mailbox, err := chain.deploy(ctx, "mailbox", mailboxArtifact, spec.HyperlaneDomain) + if err != nil { + return err + } + factory, err := chain.deploy(ctx, "staticMessageIdMultisigIsmFactory", factoryArtifact) + if err != nil { + return err + } + validators := []common.Address{r.options.HyperlaneValidator} + output, err := chain.callResult(ctx, "defaultIsm", factory, factoryArtifact, "deploy", validators, uint8(1)) + if err != nil { + return err + } + var ism common.Address + if err := factoryArtifact.ABI.UnpackOutputs("deploy", output, &ism); err != nil { + return fmt.Errorf("chain %s: decode ISM address: %w", role, err) + } + if code, err := chain.client.CodeAt(ctx, ism); err != nil { + return fmt.Errorf("chain %s: read ISM runtime code: %w", role, err) + } else if len(code) == 0 { + return fmt.Errorf("chain %s: the ISM factory deployed no code at %s", role, ism) + } + merkleTreeHook, err := chain.deploy(ctx, "merkleTreeHook", hookArtifact, mailbox) + if err != nil { + return err + } + protocolFee, err := chain.deploy(ctx, "protocolFee", feeArtifact, + new(big.Int), new(big.Int), r.deployer, r.deployer, + ) + if err != nil { + return err + } + validatorAnnounce, err := chain.deploy(ctx, "validatorAnnounce", announceArtifact, mailbox) + if err != nil { + return err + } + if _, err := chain.call(ctx, "mailbox", mailbox, mailboxArtifact, "initialize", + r.deployer, ism, protocolFee, merkleTreeHook, + ); err != nil { + return err + } + stack.mailbox = mailbox + stack.staticMessageIDMultisigIsmFactory = factory + stack.defaultIsm = ism + stack.merkleTreeHook = merkleTreeHook + stack.protocolFee = protocolFee + // The registry aliases the interchain gas paymaster to the protocol fee + // hook, exactly as `materialize_multihop_hyperlane_registry` does. + stack.interchainGasPaymaster = protocolFee + stack.validatorAnnounce = validatorAnnounce + return nil +} + +// bootstrapLayerZero reproduces `DeployLayerZeroNative.s.sol`: the endpoint, +// both ULN 302 libraries, the treasury, the upgradeable price feed and +// executor, the DVN, the fee libraries, and every default configuration call. +func (r *deployment) bootstrapLayerZero( + ctx context.Context, + role string, + remoteEIDs []uint32, + stack *protocolStack, +) error { + chain := r.chains[role] + spec := chain.spec + endpointArtifact, err := r.loadLayerZero("EndpointV2.sol", "EndpointV2") + if err != nil { + return err + } + sendUlnArtifact, err := r.loadLayerZero("SendUln302.sol", "SendUln302") + if err != nil { + return err + } + receiveUlnArtifact, err := r.loadLayerZero("ReceiveUln302.sol", "ReceiveUln302") + if err != nil { + return err + } + treasuryArtifact, err := r.loadLayerZero("Treasury.sol", "Treasury") + if err != nil { + return err + } + priceFeedArtifact, err := r.loadLayerZero("PriceFeed.sol", "PriceFeed") + if err != nil { + return err + } + dvnArtifact, err := r.loadLayerZero("DVN.sol", "DVN") + if err != nil { + return err + } + dvnFeeLibArtifact, err := r.loadLayerZero("DVNFeeLib.sol", "DVNFeeLib") + if err != nil { + return err + } + executorArtifact, err := r.loadLayerZero("Executor.sol", "Executor") + if err != nil { + return err + } + executorFeeLibArtifact, err := r.loadLayerZero("ExecutorFeeLib.sol", "ExecutorFeeLib") + if err != nil { + return err + } + proxyArtifact, err := r.loadLayerZero("ERC1967Proxy.sol", "ERC1967Proxy") + if err != nil { + return err + } + + endpoint, err := chain.deploy(ctx, "endpoint_v2", endpointArtifact, spec.LayerZeroEID, r.deployer) + if err != nil { + return err + } + sendUln, err := chain.deploy(ctx, "send_uln_302", sendUlnArtifact, + endpoint, big.NewInt(100_000), big.NewInt(100_000), + ) + if err != nil { + return err + } + receiveUln, err := chain.deploy(ctx, "receive_uln_302", receiveUlnArtifact, endpoint) + if err != nil { + return err + } + treasury, err := chain.deploy(ctx, "treasury", treasuryArtifact) + if err != nil { + return err + } + if _, err := chain.call(ctx, "send_uln_302", sendUln, sendUlnArtifact, "setTreasury", treasury); err != nil { + return err + } + + priceFeedImplementation, err := chain.deploy(ctx, "price_feed_implementation", priceFeedArtifact) + if err != nil { + return err + } + priceFeedInit, err := priceFeedArtifact.ABI.PackCall("initialize", r.deployer) + if err != nil { + return err + } + priceFeed, err := chain.deploy(ctx, "price_feed", proxyArtifact, priceFeedImplementation, priceFeedInit) + if err != nil { + return err + } + nativePrice := new(big.Int).Exp(big.NewInt(10), big.NewInt(20), nil) + if _, err := chain.call(ctx, "price_feed", priceFeed, priceFeedArtifact, + "setNativeTokenPriceUSD", nativePrice, + ); err != nil { + return err + } + prices := make([]priceFeedUpdate, 0, len(remoteEIDs)) + for _, eid := range remoteEIDs { + prices = append(prices, priceFeedUpdate{ + EID: eid, + Price: priceFeedPrice{PriceRatio: nativePrice, GasPriceInUnit: 1, GasPerByte: 1}, + }) + } + if _, err := chain.call(ctx, "price_feed", priceFeed, priceFeedArtifact, "setPrice", prices); err != nil { + return err + } + + messageLibs := []common.Address{sendUln, receiveUln} + signers := []common.Address{r.options.LayerZeroWorker} + admins := []common.Address{r.deployer, r.options.LayerZeroWorker} + dvn, err := chain.deploy(ctx, "dvn", dvnArtifact, + spec.LayerZeroEID, spec.LayerZeroEID, messageLibs, priceFeed, signers, uint64(1), admins, + ) + if err != nil { + return err + } + dvnFeeLib, err := chain.deploy(ctx, "dvn_fee_lib", dvnFeeLibArtifact, + spec.LayerZeroEID, new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + ) + if err != nil { + return err + } + if _, err := chain.call(ctx, "dvn", dvn, dvnArtifact, "setWorkerFeeLib", dvnFeeLib); err != nil { + return err + } + + executorImplementation, err := chain.deploy(ctx, "executor_implementation", executorArtifact) + if err != nil { + return err + } + executorInit, err := executorArtifact.ABI.PackCall( + "initialize", endpoint, common.Address{}, messageLibs, priceFeed, r.deployer, admins, + ) + if err != nil { + return err + } + executor, err := chain.deploy(ctx, "executor", proxyArtifact, executorImplementation, executorInit) + if err != nil { + return err + } + executorFeeLib, err := chain.deploy(ctx, "executor_fee_lib", executorFeeLibArtifact, + spec.LayerZeroEID, new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + ) + if err != nil { + return err + } + if _, err := chain.call(ctx, "executor", executor, executorArtifact, + "setWorkerFeeLib", executorFeeLib, + ); err != nil { + return err + } + + dvnConfigs := make([]dvnDstConfigParam, 0, len(remoteEIDs)) + executorConfigs := make([]executorDstConfigParam, 0, len(remoteEIDs)) + sendExecutorConfigs := make([]setDefaultExecutorConfigParam, 0, len(remoteEIDs)) + ulnConfigs := make([]setDefaultUlnConfigParam, 0, len(remoteEIDs)) + requiredDVNs := []common.Address{dvn} + for _, eid := range remoteEIDs { + dvnConfigs = append(dvnConfigs, dvnDstConfigParam{ + DestinationEID: eid, + Gas: 5_000, + MultiplierBps: 10_000, + FloorMarginUSD: big.NewInt(0), + }) + executorConfigs = append(executorConfigs, executorDstConfigParam{ + DestinationEID: eid, + LzReceiveBaseGas: 5_000, + LzComposeBaseGas: 0, + MultiplierBps: 10_000, + FloorMarginUSD: big.NewInt(0), + NativeCap: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + }) + ulnConfigs = append(ulnConfigs, setDefaultUlnConfigParam{ + EID: eid, + Config: ulnConfig{ + Confirmations: 1, + RequiredDVNCount: 1, + RequiredDVNs: requiredDVNs, + OptionalDVNs: []common.Address{}, + }, + }) + sendExecutorConfigs = append(sendExecutorConfigs, setDefaultExecutorConfigParam{ + EID: eid, + Config: executorConfig{MaxMessageSize: 1_000, Executor: executor}, + }) + } + if _, err := chain.call(ctx, "dvn", dvn, dvnArtifact, "setDstConfig", dvnConfigs); err != nil { + return err + } + if _, err := chain.call(ctx, "executor", executor, executorArtifact, + "setDstConfig", executorConfigs, + ); err != nil { + return err + } + if _, err := chain.call(ctx, "send_uln_302", sendUln, sendUlnArtifact, + "setDefaultUlnConfigs", ulnConfigs, + ); err != nil { + return err + } + if _, err := chain.call(ctx, "send_uln_302", sendUln, sendUlnArtifact, + "setDefaultExecutorConfigs", sendExecutorConfigs, + ); err != nil { + return err + } + if _, err := chain.call(ctx, "receive_uln_302", receiveUln, receiveUlnArtifact, + "setDefaultUlnConfigs", ulnConfigs, + ); err != nil { + return err + } + for _, library := range []common.Address{sendUln, receiveUln} { + if _, err := chain.call(ctx, "endpoint_v2", endpoint, endpointArtifact, + "registerLibrary", library, + ); err != nil { + return err + } + } + for _, eid := range remoteEIDs { + if _, err := chain.call(ctx, "endpoint_v2", endpoint, endpointArtifact, + "setDefaultSendLibrary", eid, sendUln, + ); err != nil { + return err + } + if _, err := chain.call(ctx, "endpoint_v2", endpoint, endpointArtifact, + "setDefaultReceiveLibrary", eid, receiveUln, big.NewInt(0), + ); err != nil { + return err + } + } + + stack.endpointV2 = endpoint + stack.sendUln302 = sendUln + stack.receiveUln302 = receiveUln + stack.treasury = treasury + stack.priceFeedImplementation = priceFeedImplementation + stack.priceFeed = priceFeed + stack.dvn = dvn + stack.dvnFeeLib = dvnFeeLib + stack.executorImplementation = executorImplementation + stack.executor = executor + stack.executorFeeLib = executorFeeLib + return nil +} + +// writeProtocolDocuments writes the carrier documents where the Python tooling +// reads them: the Hyperlane native deployment, the Hyperlane registry entry of +// the chain, and the LayerZero deployment. +func (r *deployment) writeProtocolDocuments( + role string, + spec ChainSpec, + stack *protocolStack, +) error { + hyperlanePayload, err := json.MarshalIndent(stack.document.Hyperlane, "", " ") + if err != nil { + return fmt.Errorf("deploy: encode Hyperlane deployment: %w", err) + } + if err := writeAtomic( + filepath.Join(r.layout.hyperlaneNative, fmt.Sprintf("%d.json", spec.ChainID)), + append(hyperlanePayload, '\n'), + ); err != nil { + return err + } + layerZeroPayload, err := json.MarshalIndent(stack.document.LayerZero, "", " ") + if err != nil { + return fmt.Errorf("deploy: encode LayerZero deployment: %w", err) + } + if err := writeAtomic( + filepath.Join(r.layout.layerZero, fmt.Sprintf("%d.json", spec.ChainID)), + append(layerZeroPayload, '\n'), + ); err != nil { + return err + } + registry := map[string]string{} + for key, address := range stack.hyperlaneContracts() { + registry[key] = address.Hex() + } + registry["interchainGasPaymaster"] = stack.interchainGasPaymaster.Hex() + path := filepath.Join( + r.layout.hyperlaneRegistry, "xirlocalchain"+role, "addresses.yaml", + ) + return writeAtomic(path, []byte(renderAddressYAML(registry))) +} + +// renderAddressYAML renders a flat address mapping with sorted keys, the shape +// `materialize_multihop_hyperlane_registry` writes for the Hyperlane registry. +func renderAddressYAML(addresses map[string]string) string { + var builder strings.Builder + for _, key := range sortedKeys(addresses) { + builder.WriteString(key) + builder.WriteString(": \"") + builder.WriteString(addresses[key]) + builder.WriteString("\"\n") + } + return builder.String() +} + +func addressStrings(addresses map[string]common.Address) map[string]string { + out := make(map[string]string, len(addresses)) + for key, address := range addresses { + out[key] = address.Hex() + } + return out +} + +// remoteEIDs returns the endpoint ids of every other chain, in chain order, +// which is what `DeployLayerZeroNative.remoteEidsFor` produces. +func remoteEIDs(specs []ChainSpec, local string) []uint32 { + out := make([]uint32, 0, len(specs)) + for _, spec := range specs { + if spec.Role != local { + out = append(out, spec.LayerZeroEID) + } + } + return out +} + +// ensureDirectory creates a private runtime directory. +func ensureDirectory(path string, mode os.FileMode) error { + if err := os.MkdirAll(path, mode); err != nil { + return fmt.Errorf("deploy: create %s: %w", path, err) + } + return nil +} diff --git a/go-runtime/internal/deploy/setup.go b/go-runtime/internal/deploy/setup.go new file mode 100644 index 0000000..10b2a7b --- /dev/null +++ b/go-runtime/internal/deploy/setup.go @@ -0,0 +1,296 @@ +package deploy + +import ( + "context" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// Action phases of one deployment. The protocol bootstrap of a chain is +// reported separately from the application because the carriers are reused by +// the campaign and are not charged to the XIR application. +const ( + phaseProtocol = "protocol" + phaseApplication = "application" +) + +// newDeployment validates the request, opens the private runtime tree, and +// dials every chain. +func newDeployment(ctx context.Context, chains []ChainSpec, options Options) (*deployment, error) { + specs, order, routes, err := validate(chains, options) + if err != nil { + return nil, err + } + layout := newLayout(options.RuntimeRoot) + if err := ensureDirectory(layout.work, 0o700); err != nil { + return nil, err + } + if err := ensureDirectory(layout.spool, 0o700); err != nil { + return nil, err + } + entries, err := openJournal(layout.journal) + if err != nil { + return nil, err + } + store, err := state.Open(layout.state) + if err != nil { + entries.close() + return nil, fmt.Errorf("deploy: open state store: %w", err) + } + spool, err := filepath.Abs(layout.spool) + if err != nil { + store.Close() + entries.close() + return nil, fmt.Errorf("deploy: resolve receipt spool: %w", err) + } + run := &deployment{ + options: options, + specs: specs, + routes: routes, + layout: layout, + chains: make(map[string]*chainRuntime, len(specs)), + order: order, + store: store, + entries: entries, + manifest: make(map[string]map[string]string, len(specs)), + stack: make(map[string]*protocolStack, len(specs)), + gatewayIDs: make(map[string]xir.TypedID, len(specs)), + gatewayHashes: make(map[string][32]byte, len(specs)), + profileHashes: make(map[string]map[int][32]byte, len(routes)), + artifactCache: map[string]artifactCacheEntry{}, + protocolDocuments: make(map[string]ProtocolDeploymentDocument, len(specs)), + } + for index, spec := range specs { + runtime, err := newChainRuntime(ctx, spec, options, store, spool, entries) + if err != nil { + run.close() + return nil, err + } + runtime.owner = run + runtime.phase = phaseProtocol + run.chains[spec.Role] = runtime + run.manifest[spec.Role] = map[string]string{} + if index == 0 { + run.deployer = runtime.deployer + } + identifier, err := xir.GatewayTypedID(spec.ChainID) + if err != nil { + run.close() + return nil, fmt.Errorf("chain %s: %w", spec.Role, err) + } + hash, err := identifier.Hash() + if err != nil { + run.close() + return nil, fmt.Errorf("chain %s: %w", spec.Role, err) + } + run.gatewayIDs[spec.Role] = identifier + run.gatewayHashes[spec.Role] = hash + } + for _, route := range routes { + hashes := make(map[int][32]byte, len(route)) + for hopIndex := 1; hopIndex <= len(route); hopIndex++ { + hash, err := xir.ProfileHash(route, hopIndex) + if err != nil { + run.close() + return nil, err + } + hashes[hopIndex] = hash + } + run.profileHashes[route] = hashes + } + return run, nil +} + +// validate normalises the chain specs and the route selection and rejects a +// request that cannot produce a coherent deployment. +func validate(chains []ChainSpec, options Options) ([]ChainSpec, []string, []string, error) { + if len(chains) < 2 || len(chains) > len(chainRoles) { + return nil, nil, nil, fmt.Errorf( + "deploy: a deployment needs between 2 and %d chains, got %d", len(chainRoles), len(chains), + ) + } + specs := make([]ChainSpec, len(chains)) + order := make([]string, len(chains)) + seenChainID := map[uint64]string{} + seenDomain := map[uint32]string{} + seenEID := map[uint32]string{} + for index, spec := range chains { + if spec.Role != chainRoles[index] { + return nil, nil, nil, fmt.Errorf( + "deploy: chain %d has role %q, want %q", index, spec.Role, chainRoles[index], + ) + } + if spec.ChainID == 0 { + return nil, nil, nil, fmt.Errorf("deploy: chain %s has no chain id", spec.Role) + } + if spec.RPCEndpoint == "" { + return nil, nil, nil, fmt.Errorf("deploy: chain %s has no RPC endpoint", spec.Role) + } + if other, ok := seenChainID[spec.ChainID]; ok { + return nil, nil, nil, fmt.Errorf( + "deploy: chains %s and %s share chain id %d", other, spec.Role, spec.ChainID, + ) + } + if other, ok := seenDomain[spec.HyperlaneDomain]; ok { + return nil, nil, nil, fmt.Errorf( + "deploy: chains %s and %s share Hyperlane domain %d", + other, spec.Role, spec.HyperlaneDomain, + ) + } + if other, ok := seenEID[spec.LayerZeroEID]; ok { + return nil, nil, nil, fmt.Errorf( + "deploy: chains %s and %s share LayerZero eid %d", + other, spec.Role, spec.LayerZeroEID, + ) + } + seenChainID[spec.ChainID] = spec.Role + seenDomain[spec.HyperlaneDomain] = spec.Role + seenEID[spec.LayerZeroEID] = spec.Role + specs[index] = spec + order[index] = spec.Role + } + routes, err := validateRoutes(options.Routes, len(specs)) + if err != nil { + return nil, nil, nil, err + } + if err := validateOptions(options); err != nil { + return nil, nil, nil, err + } + return specs, order, routes, nil +} + +// validateOptions rejects a request the deployment cannot honour. The request +// itself is checked before the artifact trees are resolved, so a caller who +// mixed up the roles or the route selection is told that instead of being told +// that a forge output is missing. +func validateOptions(options Options) error { + if _, err := evm.NewSigner(options.DeployerKey, big.NewInt(1)); err != nil { + return fmt.Errorf("deploy: invalid deployer key: %w", err) + } + for _, required := range []struct { + field string + value string + }{ + {"artifacts root", options.ArtifactsRoot}, + {"protocol artifacts root", options.ProtocolArtifactsRoot}, + {"runtime root", options.RuntimeRoot}, + {"deployment output path", options.OutputPath}, + } { + if strings.TrimSpace(required.value) == "" { + return fmt.Errorf("deploy: %s is not set", required.field) + } + } + if options.RootSigner == (common.Address{}) { + return fmt.Errorf("deploy: the root signer address is not set") + } + if options.Runner == (common.Address{}) { + return fmt.Errorf("deploy: the runner address is not set") + } + if options.Runner == options.RootSigner { + return fmt.Errorf("deploy: the runner and the root signer must be distinct") + } + if options.LayerZeroWorker == (common.Address{}) { + return fmt.Errorf("deploy: the LayerZero worker address is not set") + } + if options.HyperlaneValidator == (common.Address{}) { + return fmt.Errorf("deploy: the Hyperlane validator address is not set") + } + if info, err := os.Stat(options.ArtifactsRoot); err != nil || !info.IsDir() { + return fmt.Errorf("deploy: artifacts root %s is not a directory", options.ArtifactsRoot) + } + for _, stack := range []string{hyperlaneProject, layerZeroProject} { + path := filepath.Join(options.ProtocolArtifactsRoot, stack, "out") + if info, err := os.Stat(path); err != nil || !info.IsDir() { + return fmt.Errorf("deploy: protocol artifacts %s are missing", path) + } + } + return nil +} + +// validateRoutes keeps only preregistered routes, drops duplicates, and +// returns them in preregistration order so the sequence is reproducible +// whatever order the caller used. +func validateRoutes(routes []string, chainCount int) ([]string, error) { + if len(routes) == 0 { + return nil, fmt.Errorf("deploy: no routes were requested") + } + requested := map[string]bool{} + for _, route := range routes { + found := false + for _, known := range routeOrder { + if known == route { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("deploy: route %q is not preregistered", route) + } + if len(route)+1 > chainCount { + return nil, fmt.Errorf( + "deploy: route %q needs %d chains but only %d were given", + route, len(route)+1, chainCount, + ) + } + requested[route] = true + } + selected := make([]string, 0, len(requested)) + for _, route := range routeOrder { + if requested[route] { + selected = append(selected, route) + } + } + return selected, nil +} + +// loadApp loads one application artifact from the XIR forge output. +func (r *deployment) loadApp(contract string) (artifacts.Artifact, error) { + return r.loadArtifact("app:"+contract, func() (artifacts.Artifact, error) { + return artifacts.Load(r.options.ArtifactsRoot, contract) + }) +} + +// loadHyperlane loads one artifact of the Hyperlane native project. The source +// file is given explicitly because some contract names differ from their file. +func (r *deployment) loadHyperlane(sourceFile, contract string) (artifacts.Artifact, error) { + return r.loadArtifact("hyperlane:"+sourceFile+":"+contract, func() (artifacts.Artifact, error) { + return artifacts.LoadFrom(filepath.Join(r.options.ProtocolArtifactsRoot, hyperlaneProject, "out"), sourceFile, contract) + }) +} + +// loadLayerZero loads one artifact of the LayerZero native project. +func (r *deployment) loadLayerZero(sourceFile, contract string) (artifacts.Artifact, error) { + return r.loadArtifact("layerzero:"+sourceFile+":"+contract, func() (artifacts.Artifact, error) { + return artifacts.LoadFrom(filepath.Join(r.options.ProtocolArtifactsRoot, layerZeroProject, "out"), sourceFile, contract) + }) +} + +func (r *deployment) loadArtifact(key string, load func() (artifacts.Artifact, error)) (artifacts.Artifact, error) { + if cached, ok := r.artifactCache[key]; ok { + return cached.artifact, cached.err + } + artifact, err := load() + if err != nil { + err = fmt.Errorf("deploy: %w", err) + } + r.artifactCache[key] = artifactCacheEntry{artifact: artifact, err: err} + return artifact, err +} + +// addAction files one completed action under the phase that produced it. +func (r *deployment) addAction(phase string, record actionRecord) { + if phase == phaseProtocol { + r.protocolRecords = append(r.protocolRecords, record) + return + } + r.appRecords = append(r.appRecords, record) +} diff --git a/go-runtime/internal/deploy/xir_app.go b/go-runtime/internal/deploy/xir_app.go new file mode 100644 index 0000000..54e2dcb --- /dev/null +++ b/go-runtime/internal/deploy/xir_app.go @@ -0,0 +1,339 @@ +package deploy + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// transitionRecorderRoles are the chains `_deploy_gateways_and_receivers` +// gives a transition recorder: the intermediate links of the A..E topology. +var transitionRecorderRoles = map[string]bool{"b": true, "c": true, "d": true} + +// deployApplication reproduces `NativeMultihopDeployer.run` for the requested +// routes only: registries, gateways, receivers and transition recorders, then +// the route adapters, then the adapter and registry configuration. +// +// Route adapters are deployed per requested route, so a two-hop route needs +// four of them where the full route universe needs sixty-two, and the registry +// profiles and prior verifier bindings are written for exactly the hops that +// were deployed. Requesting every route over five chains deploys the same +// application the Python campaign deploys. +func (r *deployment) deployApplication(ctx context.Context) error { + for _, role := range r.order { + r.chains[role].phase = phaseApplication + } + if err := r.deployGatewaysAndReceivers(ctx); err != nil { + return err + } + if err := r.deployRouteAdapters(ctx); err != nil { + return err + } + if err := r.configureRouteAdapters(ctx); err != nil { + return err + } + return r.configureRouteRegistries(ctx) +} + +func (r *deployment) deployGatewaysAndReceivers(ctx context.Context) error { + registryArtifact, err := r.loadApp("XIRRegistry") + if err != nil { + return err + } + gatewayArtifact, err := r.loadApp("XIRGateway") + if err != nil { + return err + } + receiverArtifact, err := r.loadApp("NativeMultihopReceiver") + if err != nil { + return err + } + recorderArtifact, err := r.loadApp("NativeMultihopTransitionRecorder") + if err != nil { + return err + } + for _, role := range r.order { + chain := r.chains[role] + registry, err := chain.deploy(ctx, "registry", registryArtifact, r.deployer) + if err != nil { + return err + } + gateway, err := chain.deploy(ctx, "gateway", gatewayArtifact, registry, r.gatewayIDs[role].ABI()) + if err != nil { + return err + } + r.manifest[role]["registry"] = registry.Hex() + r.manifest[role]["gateway"] = gateway.Hex() + if role != r.order[0] { + initialState := xir.Keccak256([]byte("XIR_NATIVE_MULTIHOP_INITIAL_STATE_V1:" + role)) + receiver, err := chain.deploy(ctx, "receiver", receiverArtifact, gateway, initialState) + if err != nil { + return err + } + r.manifest[role]["receiver"] = receiver.Hex() + } + if transitionRecorderRoles[role] { + recorder, err := chain.deploy( + ctx, "transition_recorder", recorderArtifact, gateway, r.options.Runner, + ) + if err != nil { + return err + } + r.manifest[role]["transition_recorder"] = recorder.Hex() + } + } + return nil +} + +func (r *deployment) deployRouteAdapters(ctx context.Context) error { + hyperlaneArtifact, err := r.loadApp("HyperlaneAdapter") + if err != nil { + return err + } + layerZeroArtifact, err := r.loadApp("LayerZeroAdapter") + if err != nil { + return err + } + // The peer placeholder is the deployer address in a bytes32 word; the real + // peer is bound by `_configure_route_adapters`. + placeholder := bytes32Address(r.deployer) + for _, route := range r.routes { + for hopIndex := 1; hopIndex <= len(route); hopIndex++ { + source := r.order[hopIndex-1] + destination := r.order[hopIndex] + sourceKey, err := xir.AdapterKey(route, hopIndex, "out") + if err != nil { + return err + } + destinationKey, err := xir.AdapterKey(route, hopIndex, "in") + if err != nil { + return err + } + var sourceArtifact artifacts.Artifact + var sourceArgs, destinationArgs []any + if route[hopIndex-1] == 'H' { + sourceArtifact = hyperlaneArtifact + sourceArgs = []any{ + r.stack[source].mailbox, + r.specs[hopIndex].HyperlaneDomain, + placeholder, + r.deployer, + r.options.Runner, + } + destinationArgs = []any{ + r.stack[destination].mailbox, + r.specs[hopIndex-1].HyperlaneDomain, + placeholder, + r.deployer, + r.options.Runner, + } + } else { + sourceArtifact = layerZeroArtifact + sourceArgs = []any{ + r.stack[source].endpointV2, + r.specs[hopIndex].LayerZeroEID, + placeholder, + r.deployer, + r.options.Runner, + } + destinationArgs = []any{ + r.stack[destination].endpointV2, + r.specs[hopIndex-1].LayerZeroEID, + placeholder, + r.deployer, + r.options.Runner, + } + } + if address, err := r.chains[source].deploy(ctx, sourceKey, sourceArtifact, sourceArgs...); err != nil { + return err + } else { + r.manifest[source][sourceKey] = address.Hex() + } + if address, err := r.chains[destination].deploy( + ctx, destinationKey, sourceArtifact, destinationArgs..., + ); err != nil { + return err + } else { + r.manifest[destination][destinationKey] = address.Hex() + } + } + } + return nil +} + +func (r *deployment) configureRouteAdapters(ctx context.Context) error { + hyperlaneArtifact, err := r.loadApp("HyperlaneAdapter") + if err != nil { + return err + } + layerZeroArtifact, err := r.loadApp("LayerZeroAdapter") + if err != nil { + return err + } + options, err := receiveOptions(receiveOptionsGasLimit) + if err != nil { + return err + } + for _, route := range r.routes { + for hopIndex := 1; hopIndex <= len(route); hopIndex++ { + source := r.order[hopIndex-1] + destination := r.order[hopIndex] + sourceKey, err := xir.AdapterKey(route, hopIndex, "out") + if err != nil { + return err + } + destinationKey, err := xir.AdapterKey(route, hopIndex, "in") + if err != nil { + return err + } + hyperlane := route[hopIndex-1] == 'H' + artifact := layerZeroArtifact + setter := "setRemotePeer" + if hyperlane { + artifact = hyperlaneArtifact + setter = "setRemoteAdapter" + } + remoteIn := bytes32Address(common.HexToAddress(r.manifest[destination][destinationKey])) + remoteOut := bytes32Address(common.HexToAddress(r.manifest[source][sourceKey])) + if _, err := r.chains[source].call( + ctx, sourceKey, common.HexToAddress(r.manifest[source][sourceKey]), + artifact, setter, remoteIn, + ); err != nil { + return err + } + if _, err := r.chains[destination].call( + ctx, destinationKey, common.HexToAddress(r.manifest[destination][destinationKey]), + artifact, setter, remoteOut, + ); err != nil { + return err + } + if !hyperlane { + for _, side := range []struct { + role, key string + }{{source, sourceKey}, {destination, destinationKey}} { + if _, err := r.chains[side.role].call( + ctx, side.key, common.HexToAddress(r.manifest[side.role][side.key]), + artifact, "setEnforcedOptions", options, + ); err != nil { + return err + } + } + } + if hopIndex > 1 { + priorKey, err := xir.AdapterKey(route, hopIndex-1, "in") + if err != nil { + return err + } + verifier := common.HexToAddress(r.manifest[source][priorKey]) + for priorHop := 1; priorHop < hopIndex; priorHop++ { + if _, err := r.chains[source].call( + ctx, sourceKey, common.HexToAddress(r.manifest[source][sourceKey]), + artifact, "setPriorVerifier", r.profileHashes[route][priorHop], verifier, + ); err != nil { + return err + } + } + } + } + } + return nil +} + +// configureRouteRegistries writes the root of the source gateway into every +// other registry, and every profile of every deployed hop into the registry of +// the chain that completes it. +func (r *deployment) configureRouteRegistries(ctx context.Context) error { + registryArtifact, err := r.loadApp("XIRRegistry") + if err != nil { + return err + } + source := r.order[0] + for _, role := range r.order[1:] { + if err := r.setRoot(ctx, registryArtifact, role, r.gatewayHashes[source]); err != nil { + return err + } + } + for _, route := range r.routes { + for completed := 1; completed <= len(route); completed++ { + role := r.order[completed] + inboundKey, err := xir.AdapterKey(route, completed, "in") + if err != nil { + return err + } + verifier := common.HexToAddress(r.manifest[role][inboundKey]) + for receiptIndex := 1; receiptIndex <= completed; receiptIndex++ { + if err := r.setProfile( + ctx, + registryArtifact, + role, + r.profileHashes[route][receiptIndex], + r.gatewayHashes[r.order[receiptIndex-1]], + r.gatewayHashes[r.order[receiptIndex]], + verifier, + ); err != nil { + return err + } + } + } + } + return nil +} + +// setRoot registers the source gateway hash as version 1 of the registry of +// one chain (`NativeMultihopDeployer._set_root`). +func (r *deployment) setRoot( + ctx context.Context, + artifact artifacts.Artifact, + role string, + gatewayHash [32]byte, +) error { + snapshot := registryRootSnapshot{ + GatewayHash: gatewayHash, + Signer: r.options.RootSigner, + ValidAfter: 0, + ValidUntil: 0, + Enabled: true, + } + _, err := r.chains[role].call( + ctx, "registry", common.HexToAddress(r.manifest[role]["registry"]), + artifact, "setRoot", xir.RegistryVersion, snapshot, + ) + return err +} + +// setProfile registers one hop profile in the registry of the chain that +// completes the hop (`NativeMultihopDeployer._set_multihop_profile`). +func (r *deployment) setProfile( + ctx context.Context, + artifact artifacts.Artifact, + role string, + profileHash [32]byte, + sourceHash [32]byte, + destinationHash [32]byte, + adapter common.Address, +) error { + snapshot := registryProfileSnapshot{ + SourceHash: sourceHash, + DestinationHash: destinationHash, + Adapter: adapter, + SecurityLevel: 1, + ValidAfter: 0, + ValidUntil: 0, + Enabled: true, + } + _, err := r.chains[role].call( + ctx, "registry", common.HexToAddress(r.manifest[role]["registry"]), + artifact, "setProfile", profileHash, snapshot, + ) + return err +} + +// bytes32Address right-aligns an address in a bytes32 word, the peer encoding +// `_deploy_route_adapters` uses for the placeholder peer. +func bytes32Address(address common.Address) [32]byte { + var out [32]byte + copy(out[12:], address.Bytes()) + return out +} diff --git a/go-runtime/internal/evm/anvil_test.go b/go-runtime/internal/evm/anvil_test.go new file mode 100644 index 0000000..f575b7b --- /dev/null +++ b/go-runtime/internal/evm/anvil_test.go @@ -0,0 +1,438 @@ +package evm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "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/rpc" + + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +// anvilBinaryCandidates are the locations the runtime uses for the local node; +// the integration test skips only when none of them exists. +var anvilBinaryCandidates = []string{ + "anvil", + "/home/ubuntu/.foundry/bin/anvil", +} + +// firstAvailableBinary resolves the first candidate that exists, so the +// integration tests skip only when every documented location is genuinely +// absent. +func firstAvailableBinary(candidates []string) (string, bool) { + for _, candidate := range candidates { + if path, err := exec.LookPath(candidate); err == nil { + return path, true + } + } + return "", false +} + +func findAnvil(t *testing.T) string { + t.Helper() + path, found := firstAvailableBinary(anvilBinaryCandidates) + if !found { + t.Skip("anvil is not installed, so the anvil integration test is skipped") + } + return path +} + +// TestAnvilSkipCondition proves the skip above fires only for a genuinely +// missing binary, and that this host runs the integration tests for real. +func TestAnvilSkipCondition(t *testing.T) { + if path, found := firstAvailableBinary([]string{"/nonexistent/xir-anvil-probe"}); found { + t.Fatalf("an absent binary resolved to %q", path) + } + if _, found := firstAvailableBinary(anvilBinaryCandidates); !found { + t.Skip("anvil is genuinely absent on this host, so the integration tests skip") + } +} + +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("cannot reserve a port: %v", err) + } + defer listener.Close() + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address %T", listener.Addr()) + } + return address.Port +} + +type anvilNode struct { + endpoint string + command *exec.Cmd + output strings.Builder + mu sync.Mutex +} + +// startAnvil launches a disposable local chain and waits until it answers RPC. +func startAnvil(t *testing.T) *anvilNode { + t.Helper() + binary := findAnvil(t) + port := freePort(t) + node := &anvilNode{endpoint: fmt.Sprintf("http://127.0.0.1:%d", port)} + node.command = exec.Command(binary, "--port", fmt.Sprint(port), "--silent") + node.command.Stdout = &node.output + node.command.Stderr = &node.output + if err := node.command.Start(); err != nil { + t.Fatalf("cannot start anvil: %v", err) + } + t.Cleanup(func() { + if node.command.Process != nil { + _ = node.command.Process.Kill() + } + _ = node.command.Wait() + }) + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + connection, err := rpc.DialContext(context.Background(), node.endpoint) + if err == nil { + var chainID string + if err := connection.CallContext(context.Background(), &chainID, "eth_chainId"); err == nil { + connection.Close() + return node + } + connection.Close() + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("anvil did not become ready at %s:\n%s", node.endpoint, node.output.String()) + return nil +} + +func (n *anvilNode) logs() string { + n.mu.Lock() + defer n.mu.Unlock() + return n.output.String() +} + +// fund credits an account through anvil's administrative RPC, so the test signs +// with the repository fixture keys instead of a well-known development key. +func (n *anvilNode) fund(t *testing.T, address common.Address, wei *big.Int) { + t.Helper() + connection, err := rpc.DialContext(context.Background(), n.endpoint) + if err != nil { + t.Fatalf("cannot dial anvil: %v", err) + } + defer connection.Close() + var accepted bool + if err := connection.CallContext( + context.Background(), &accepted, "anvil_setBalance", address, hexutil.EncodeBig(wei), + ); err != nil { + t.Fatalf("anvil_setBalance: %v", err) + } +} + +// TestAnvilTransactorRoundTrip sends one EIP-1559 value transfer through the +// transactor, proves the persisted receipt digest matches the spooled receipt, +// and proves the action is still succeeded after the store is reopened. +func TestAnvilTransactorRoundTrip(t *testing.T) { + node := startAnvil(t) + vectors := loadVectors(t) + privateKey := vectors.Constants.FixtureKeys["validator"] + if privateKey == "" { + t.Fatal("vectors carry no validator fixture key") + } + client, err := Dial(context.Background(), node.endpoint, 10*time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer client.Close() + chainID, err := client.ChainID(context.Background()) + if err != nil { + t.Fatalf("ChainID: %v", err) + } + signer, err := NewSigner(privateKey, chainID) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + node.fund(t, signer.Address(), new(big.Int).Mul(big.NewInt(1_000), big.NewInt(1e18))) + + databasePath := filepath.Join(t.TempDir(), "runtime", "runner.sqlite") + store, err := state.Open(databasePath) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + spoolRoot := filepath.Join(t.TempDir(), "private-raw") + transactor, err := NewTransactor(client, signer, store, spoolRoot, 30*time.Second) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + recipient := common.HexToAddress("0x00000000000000000000000000000000000000b0") + request := TxRequest{ + ActionID: ActionID("a", 0, "anvil-transfer", nil), + AttemptID: "attempt-anvil-1", + Stage: "root_create", + ChainRole: "a", + To: recipient, + Value: big.NewInt(1), + Gas: 21_000, + } + result, err := transactor.Execute(context.Background(), request) + if err != nil { + t.Fatalf("Execute: %v\n%s", err, node.logs()) + } + if result.BlockNumber == 0 || result.GasUsed != 21_000 { + t.Fatalf("result = %+v", result) + } + if result.ContractAddress != "" { + t.Errorf("a value transfer reported a contract address %q", result.ContractAddress) + } + // The receipt evidence on disk matches the digest recorded in the ledger. + contents, err := os.ReadFile(result.ReceiptPath) + if err != nil { + t.Fatalf("receipt file: %v", err) + } + if observed := sha256Hex(contents); observed != result.ReceiptSHA256 { + t.Fatalf("receipt digest = %s, want %s", observed, result.ReceiptSHA256) + } + var document map[string]any + if err := json.Unmarshal(contents, &document); err != nil { + t.Fatalf("the spooled receipt is not JSON: %v", err) + } + if hash, ok := document["transactionHash"].(string); !ok || hash != result.TransactionHash { + t.Fatalf("spooled receipt transactionHash = %#v, want %s", document["transactionHash"], result.TransactionHash) + } + // The document is rendered the way the Python runner renders it (sorted keys, + // two-space indent, trailing newline), so re-rendering it reproduces the file + // byte for byte. + reRendered, err := state.CanonicalJSONIndent(document, 2) + if err != nil { + t.Fatalf("CanonicalJSONIndent: %v", err) + } + if string(contents) != reRendered+"\n" { + t.Fatalf("the spooled receipt is not in the canonical Python layout:\n%s", contents) + } + if !strings.HasSuffix(reRendered, "}") { + t.Fatalf("re-rendered receipt = %q", reRendered) + } + for name, path := range map[string]string{ + "receipt": result.ReceiptPath, + "raw": filepath.Join(spoolRoot, strings.TrimPrefix(result.TransactionHash, "0x")+".raw"), + } { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("%s mode = %o, want 600", name, mode) + } + } + // The chain agrees: the transaction is mined and the value arrived. + onChain, err := client.Receipt(context.Background(), common.HexToHash(result.TransactionHash)) + if err != nil { + t.Fatalf("Receipt: %v", err) + } + if onChain == nil || onChain.Status != 1 { + t.Fatalf("on-chain receipt = %+v", onChain) + } + balance, err := client.BalanceAt(context.Background(), recipient) + if err != nil { + t.Fatalf("BalanceAt: %v", err) + } + if balance.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("recipient balance = %s, want 1 wei", balance) + } + action, err := store.Action(request.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if action.State != state.ActionSucceeded || action.TransactionHash != result.TransactionHash { + t.Fatalf("action = %+v", action) + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // A restart must find the same evidence. + reopened, err := state.Open(databasePath) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + persisted, err := reopened.Action(request.ActionID) + if err != nil { + t.Fatalf("Action after reopen: %v", err) + } + if persisted == nil { + t.Fatal("the action disappeared across the reopen") + } + if persisted.State != state.ActionSucceeded { + t.Fatalf("action state after reopen = %q, want succeeded", persisted.State) + } + if persisted.TransactionHash != result.TransactionHash || + persisted.ReceiptSHA256 != result.ReceiptSHA256 || + persisted.ReceiptPath != result.ReceiptPath || + persisted.BlockNumber != result.BlockNumber || + persisted.GasUsed != result.GasUsed { + t.Fatalf("persisted action = %+v, want %+v", persisted, result) + } + if err := state.VerifyActionIdentity(persisted); err != nil { + t.Fatalf("VerifyActionIdentity: %v", err) + } + // Resuming the reopened action must not broadcast a second transfer. + resumed, err := NewTransactor(client, signer, reopened, spoolRoot, 30*time.Second) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + replayed, err := resumed.Execute(context.Background(), TxRequest{ + ActionID: request.ActionID, + ChainRole: "a", + }) + if err != nil { + t.Fatalf("Execute after restart: %v", err) + } + if replayed.TransactionHash != result.TransactionHash || replayed.ReceiptSHA256 != result.ReceiptSHA256 { + t.Fatalf("replayed result = %+v", replayed) + } + balance, err = client.BalanceAt(context.Background(), recipient) + if err != nil { + t.Fatalf("BalanceAt: %v", err) + } + if balance.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("recipient balance after the resumed action = %s, want 1 wei", balance) + } +} + +// TestAnvilExecuteEstimatesGasWhenUnset proves a request without a gas limit +// follows deployer.py's rule: estimate, add 30%, and clamp to +// [100_000, 29_000_000]. A 21_000 gas transfer therefore freezes the 100_000 +// floor. +func TestAnvilExecuteEstimatesGasWhenUnset(t *testing.T) { + node := startAnvil(t) + vectors := loadVectors(t) + client, err := Dial(context.Background(), node.endpoint, 10*time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer client.Close() + chainID, err := client.ChainID(context.Background()) + if err != nil { + t.Fatalf("ChainID: %v", err) + } + signer, err := NewSigner(vectors.Constants.FixtureKeys["validator"], chainID) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + node.fund(t, signer.Address(), new(big.Int).Mul(big.NewInt(1_000), big.NewInt(1e18))) + store, err := state.Open(filepath.Join(t.TempDir(), "runtime", "runner.sqlite")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + defer store.Close() + transactor, err := NewTransactor(client, signer, store, filepath.Join(t.TempDir(), "raw"), 30*time.Second) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + request := TxRequest{ + ActionID: "action-estimated", + ChainRole: "a", + To: common.HexToAddress("0x00000000000000000000000000000000000000b1"), + Value: big.NewInt(3), + } + result, err := transactor.Execute(context.Background(), request) + if err != nil { + t.Fatalf("Execute: %v\n%s", err, node.logs()) + } + action, err := store.Action(request.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + const wantGas = 100_000 + if action.Gas != wantGas { + t.Fatalf("frozen gas = %d, want the %d floor", action.Gas, wantGas) + } + if result.GasUsed != 21_000 { + t.Fatalf("gas used = %d, want 21_000", result.GasUsed) + } + detail, err := action.Detail() + if err != nil { + t.Fatalf("Detail: %v", err) + } + frozenFee, ok := detail["max_fee_per_gas"].(string) + if !ok { + t.Fatalf("the intent did not freeze its fee cap: %#v", detail) + } + rawTransaction, err := hexutil.Decode(action.RawTransactionHex) + if err != nil { + t.Fatalf("raw transaction: %v", err) + } + var signed types.Transaction + if err := signed.UnmarshalBinary(rawTransaction); err != nil { + t.Fatalf("raw transaction: %v", err) + } + if signed.GasFeeCap().String() != frozenFee { + t.Fatalf("signed fee cap %s differs from the frozen %s", signed.GasFeeCap(), frozenFee) + } +} + +// TestAnvilClientReadSurface exercises the remaining transport calls against a +// real node: code, nonce, headers, logs and gas estimation. +func TestAnvilClientReadSurface(t *testing.T) { + node := startAnvil(t) + client, err := Dial(context.Background(), node.endpoint, 10*time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer client.Close() + target := common.HexToAddress("0x00000000000000000000000000000000000000c0") + code, err := client.CodeAt(context.Background(), target) + if err != nil { + t.Fatalf("CodeAt: %v", err) + } + if len(code) != 0 { + t.Fatalf("CodeAt = 0x%x, want no code for a fresh address", code) + } + number, err := client.BlockNumber(context.Background()) + if err != nil { + t.Fatalf("BlockNumber: %v", err) + } + header, err := client.HeaderByNumber(context.Background(), nil) + if err != nil { + t.Fatalf("HeaderByNumber: %v", err) + } + if header == nil || header.Number.Uint64() != number { + t.Fatalf("HeaderByNumber = %+v, want block %d", header, number) + } + logs, err := client.Logs(context.Background(), ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(0), + ToBlock: new(big.Int).SetUint64(number), + }) + if err != nil { + t.Fatalf("Logs: %v", err) + } + if len(logs) != 0 { + t.Fatalf("Logs = %d entries, want none on a fresh chain", len(logs)) + } + estimate, err := client.EstimateGas(context.Background(), ethereum.CallMsg{ + From: target, + To: &target, + Value: big.NewInt(0), + }) + if err != nil { + t.Fatalf("EstimateGas: %v", err) + } + // A transfer to an empty account costs the base 21_000 gas; an estimation + // allowance for the zero-value call may be higher, but never zero. + if estimate == 0 { + t.Fatalf("EstimateGas = 0, want a positive estimate") + } +} diff --git a/go-runtime/internal/evm/client.go b/go-runtime/internal/evm/client.go new file mode 100644 index 0000000..e0836e6 --- /dev/null +++ b/go-runtime/internal/evm/client.go @@ -0,0 +1,462 @@ +// Package evm is the XIR Go runtime's EVM transport: a JSON-RPC client, an +// EIP-1559/personal-digest signer, and a transactor that keeps the +// intent -> frozen calldata -> signed -> submitted -> receipt chain durable +// across a process restart. +// +// The transport rules mirror the Python reference runtime: +// +// - root_signer.Web3RootCreationSource._finalized_number reads the RPC +// “finalized“ tag and falls back to Besu's private-QBFT rule (validator +// quorum plus one successor block on the canonical chain); +// - rpc.is_transient_rpc_error classifies a failure that is safe to retry +// unchanged; +// - multihop_runner.NativeMultihopRunner._transact/_validate_durable_raw +// freeze the calldata digest, sign, persist the raw transaction, broadcast, +// and re-validate every field before a rebroadcast. +package evm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "strings" + "syscall" + "time" + + "github.com/ethereum/go-ethereum" + "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" +) + +// qbftConfirmationBlocks is the successor depth root_signer.py requires of a +// private QBFT network before it treats a block as final. +const qbftConfirmationBlocks = 1 + +// qbftValidatorQuorum is the validator count root_signer.py demands before it +// accepts the QBFT finality rule. +const qbftValidatorQuorum = 4 + +// finalizedPollInterval matches root_signer.py's 0.25 second wait between head +// observations. +const finalizedPollInterval = 250 * time.Millisecond + +// Client is a JSON-RPC client bound to one chain, with a per-call deadline. +type Client struct { + rpc *rpc.Client + eth *ethclient.Client + url string + timeout time.Duration + chainID *big.Int + qbftQuorum int +} + +// Dial connects to url and verifies the chain identity before returning, so a +// transactor can never sign for the wrong network. +func Dial(ctx context.Context, url string, timeout time.Duration) (*Client, error) { + if strings.TrimSpace(url) == "" { + return nil, errors.New("xir evm: empty RPC endpoint") + } + if timeout <= 0 { + return nil, fmt.Errorf("xir evm: RPC timeout %s is not positive", timeout) + } + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + rpcClient, err := rpc.DialContext(callCtx, url) + if err != nil { + return nil, fmt.Errorf("xir evm: cannot dial %s: %w", url, err) + } + client := &Client{ + rpc: rpcClient, + eth: ethclient.NewClient(rpcClient), + url: url, + timeout: timeout, + qbftQuorum: qbftValidatorQuorum, + } + chainID, err := client.eth.ChainID(callCtx) + if err != nil { + rpcClient.Close() + return nil, fmt.Errorf("xir evm: %s does not report a chain id: %w", url, err) + } + if chainID == nil || chainID.Sign() <= 0 { + rpcClient.Close() + return nil, fmt.Errorf("xir evm: %s reports chain id %v", url, chainID) + } + client.chainID = chainID + return client, nil +} + +// Close releases the underlying connection. +func (c *Client) Close() { + if c == nil || c.rpc == nil { + return + } + c.rpc.Close() +} + +// ChainID returns the chain id observed at dial time. +func (c *Client) ChainID(ctx context.Context) (*big.Int, error) { + observed, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (*big.Int, error) { + return c.eth.ChainID(callCtx) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_chainId failed: %w", err) + } + return observed, nil +} + +// BlockNumber returns the latest block height. +func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { + number, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (uint64, error) { + return c.eth.BlockNumber(callCtx) + }) + if err != nil { + return 0, fmt.Errorf("xir evm: eth_blockNumber failed: %w", err) + } + return number, nil +} + +// PendingNonce returns the next nonce the chain would assign, counting the +// transactions still in the pool. +func (c *Client) PendingNonce(ctx context.Context, address common.Address) (uint64, error) { + nonce, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (uint64, error) { + return c.eth.PendingNonceAt(callCtx, address) + }) + if err != nil { + return 0, fmt.Errorf("xir evm: eth_getTransactionCount failed: %w", err) + } + return nonce, nil +} + +// BalanceAt returns the account balance in wei. +func (c *Client) BalanceAt(ctx context.Context, address common.Address) (*big.Int, error) { + balance, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (*big.Int, error) { + return c.eth.BalanceAt(callCtx, address, nil) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_getBalance failed: %w", err) + } + return balance, nil +} + +// CodeAt returns the deployed bytecode of an account. +func (c *Client) CodeAt(ctx context.Context, address common.Address) ([]byte, error) { + code, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) ([]byte, error) { + return c.eth.CodeAt(callCtx, address, nil) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_getCode failed: %w", err) + } + return code, nil +} + +// CallContract performs an eth_call against the latest block. +func (c *Client) CallContract(ctx context.Context, to common.Address, data []byte) ([]byte, error) { + result, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) ([]byte, error) { + return c.eth.CallContract(callCtx, ethereum.CallMsg{To: &to, Data: data}, nil) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_call failed: %w", err) + } + return result, nil +} + +// CallContractAtHash reads the exact historical block that produced an event. +func (c *Client) CallContractAtHash(ctx context.Context, to common.Address, data []byte, hash common.Hash) ([]byte, error) { + result, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) ([]byte, error) { + return c.eth.CallContractAtHash(callCtx, ethereum.CallMsg{To: &to, Data: data}, hash) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: historical eth_call failed: %w", err) + } + return result, nil +} + +// EstimateGas returns the node's gas estimate for a call message. +func (c *Client) EstimateGas(ctx context.Context, message ethereum.CallMsg) (uint64, error) { + estimate, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (uint64, error) { + return c.eth.EstimateGas(callCtx, message) + }) + if err != nil { + return 0, fmt.Errorf("xir evm: eth_estimateGas failed: %w", err) + } + return estimate, nil +} + +// GasPrice returns the suggested gas price. +func (c *Client) GasPrice(ctx context.Context) (*big.Int, error) { + price, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (*big.Int, error) { + return c.eth.SuggestGasPrice(callCtx) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_gasPrice failed: %w", err) + } + return price, nil +} + +// Receipt returns a mined receipt, or nil when the transaction is not mined yet. +func (c *Client) Receipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { + _, receipt, err := c.ReceiptDocument(ctx, hash) + return receipt, err +} + +// ReceiptDocument returns both the node's raw receipt JSON (the exact document +// the runtime spools as evidence) and its decoded form. A transaction that is +// not mined yet yields (nil, nil, nil). +func (c *Client) ReceiptDocument(ctx context.Context, hash common.Hash) (json.RawMessage, *types.Receipt, error) { + raw, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (json.RawMessage, error) { + var result json.RawMessage + if err := c.rpc.CallContext(callCtx, &result, "eth_getTransactionReceipt", hash); err != nil { + return nil, err + } + return result, nil + }) + if err != nil { + return nil, nil, fmt.Errorf("xir evm: eth_getTransactionReceipt failed: %w", err) + } + if isJSONNull(raw) { + return nil, nil, nil + } + var receipt types.Receipt + if err := json.Unmarshal(raw, &receipt); err != nil { + return nil, nil, fmt.Errorf("xir evm: receipt %s is not decodable: %w", hash, err) + } + return raw, &receipt, nil +} + +// Logs returns the logs matching a filter query. +func (c *Client) Logs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { + logs, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) ([]types.Log, error) { + return c.eth.FilterLogs(callCtx, query) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_getLogs failed: %w", err) + } + return logs, nil +} + +// HeaderByNumber returns one header; a nil number means the latest block, and +// the negative block tags (pending/finalized/safe) are accepted. +func (c *Client) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + header, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (*types.Header, error) { + return c.eth.HeaderByNumber(callCtx, number) + }) + if err != nil { + return nil, fmt.Errorf("xir evm: eth_getBlockByNumber failed: %w", err) + } + return header, nil +} + +// SendRawTransaction broadcasts already-signed bytes. +func (c *Client) SendRawTransaction(ctx context.Context, raw []byte) error { + if len(raw) == 0 { + return errors.New("xir evm: empty raw transaction") + } + _, err := withTimeout(ctx, c.timeout, func(callCtx context.Context) (common.Hash, error) { + var hash common.Hash + if err := c.rpc.CallContext(callCtx, &hash, "eth_sendRawTransaction", hexutil.Encode(raw)); err != nil { + return common.Hash{}, err + } + return hash, nil + }) + if err != nil { + return fmt.Errorf("xir evm: eth_sendRawTransaction failed: %w", err) + } + return nil +} + +// WaitMined polls until the transaction has a receipt or the timeout elapses. +func (c *Client) WaitMined(ctx context.Context, hash common.Hash, timeout time.Duration) (*types.Receipt, error) { + if timeout <= 0 { + return nil, fmt.Errorf("xir evm: wait timeout %s is not positive", timeout) + } + deadline := time.Now().Add(timeout) + for { + receipt, err := c.Receipt(ctx, hash) + switch { + case err == nil && receipt != nil: + return receipt, nil + case err != nil && !IsTransientRPCError(err): + return nil, fmt.Errorf("xir evm: cannot read receipt %s: %w", hash, err) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("xir evm: transaction %s was not mined within %s", hash, timeout) + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("xir evm: transaction %s was not mined: %w", hash, ctx.Err()) + case <-time.After(finalizedPollInterval): + } + } +} + +// FinalizedNumber returns the finalized height and the rule that produced it, +// mirroring root_signer.Web3RootCreationSource._finalized_number: the RPC +// “finalized“ tag when the node supports it, otherwise the private-QBFT rule +// of validator quorum plus one successor block on the canonical chain. +func (c *Client) FinalizedNumber(ctx context.Context) (uint64, string, error) { + if number, rule, supported, err := c.finalizedTag(ctx); err != nil || supported { + return number, rule, err + } + head, err := c.HeaderByNumber(ctx, nil) + if err != nil { + return 0, "", err + } + return c.qbftFinalized(ctx, head.Number.Uint64(), head.Hash()) +} + +// FinalizedNumberFor applies the same rule to one specific block, which is what +// a caller holding a receipt needs: the receipt block must be at or below the +// finalized height, and on the QBFT path it must still be canonical. +func (c *Client) FinalizedNumberFor(ctx context.Context, blockNumber uint64, blockHash common.Hash) (uint64, string, error) { + if number, rule, supported, err := c.finalizedTag(ctx); err != nil || supported { + return number, rule, err + } + return c.qbftFinalized(ctx, blockNumber, blockHash) +} + +// finalizedTag reads the Ethereum “finalized“ block tag. supported is false +// when the node does not implement it, which is what private QBFT networks do. +func (c *Client) finalizedTag(ctx context.Context) (uint64, string, bool, error) { + finalized := big.NewInt(int64(rpc.FinalizedBlockNumber)) + header, err := c.HeaderByNumber(ctx, finalized) + if err != nil { + if finalizedTagUnsupported(err) { + return 0, "", false, nil + } + return 0, "", false, fmt.Errorf("xir evm: the finalized block tag failed: %w", err) + } + if header == nil { + return 0, "", false, errors.New("xir evm: the finalized block tag returned no header") + } + return header.Number.Uint64(), "rpc-finalized-tag", true, nil +} + +// qbftFinalized is the private-QBFT fallback. A QBFT block is final once the +// validator quorum has committed it, so the runtime additionally waits for one +// successor block and confirms the observed block is still canonical. +func (c *Client) qbftFinalized(ctx context.Context, blockNumber uint64, blockHash common.Hash) (uint64, string, error) { + var validators []string + if err := c.withTimeoutInto(ctx, func(callCtx context.Context) error { + return c.rpc.CallContext(callCtx, &validators, "qbft_getValidatorsByBlockNumber", "latest") + }); err != nil { + return 0, "", fmt.Errorf("xir evm: QBFT validator quorum cannot be established: %w", err) + } + if len(validators) < c.qbftQuorum { + return 0, "", fmt.Errorf( + "xir evm: QBFT validator quorum cannot be established: %d validators, want %d", + len(validators), c.qbftQuorum, + ) + } + required := blockNumber + qbftConfirmationBlocks + latest, err := c.BlockNumber(ctx) + if err != nil { + return 0, "", err + } + deadline := time.Now().Add(c.timeout) + for latest < required && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return 0, "", fmt.Errorf("xir evm: QBFT finality wait was cancelled: %w", ctx.Err()) + case <-time.After(finalizedPollInterval): + } + latest, err = c.BlockNumber(ctx) + if err != nil { + return 0, "", err + } + } + if latest < required { + return 0, "", fmt.Errorf( + "xir evm: QBFT block %d lacks the successor depth (%d < %d)", + blockNumber, latest, required, + ) + } + canonical, err := c.HeaderByNumber(ctx, new(big.Int).SetUint64(blockNumber)) + if err != nil { + return 0, "", err + } + if canonical == nil || canonical.Hash() != blockHash { + return 0, "", fmt.Errorf( + "xir evm: block %d is not in the canonical QBFT chain", blockNumber, + ) + } + return latest, fmt.Sprintf("qbft-committed-plus-%d", qbftConfirmationBlocks), nil +} + +// IsTransientRPCError reports whether a failure is safe to retry unchanged, +// mirroring rpc.is_transient_rpc_error: any transport-level failure (the Python +// runtime retries requests.RequestException), and the one RPC error Besu +// returns while a validator is still catching up. +func IsTransientRPCError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + if errors.Is(err, context.Canceled) { + return false + } + var netError net.Error + if errors.As(err, &netError) { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + for _, syscallError := range []error{ + syscall.ECONNREFUSED, syscall.ECONNRESET, syscall.EPIPE, syscall.ETIMEDOUT, syscall.EHOSTUNREACH, + } { + if errors.Is(err, syscallError) { + return true + } + } + var rpcError rpc.Error + if errors.As(err, &rpcError) { + message := strings.ToLower(rpcError.Error()) + return strings.Contains(message, "transaction pool not enabled") && + strings.Contains(message, "node not yet in sync") + } + return false +} + +// finalizedTagUnsupported reports whether a finality lookup failed because the +// node has no “finalized“ tag. The Python reference only tolerates Besu's +// "unknown block" wording; the remaining tokens cover the other nodes the +// runtime meets (Geth-style "header not found", Hardhat/Anvil rejections). +func finalizedTagUnsupported(err error) bool { + message := strings.ToLower(err.Error()) + if strings.Contains(message, "unknown block") || strings.Contains(message, "header not found") { + return true + } + if strings.Contains(message, "finalized") && + (strings.Contains(message, "unsupported") || strings.Contains(message, "not supported")) { + return true + } + return false +} + +func isJSONNull(document json.RawMessage) bool { + trimmed := strings.TrimSpace(string(document)) + return trimmed == "" || trimmed == "null" +} + +// withTimeout is a free function because Go methods cannot declare type +// parameters; every client call runs under the client's own RPC deadline. +func withTimeout[T any](ctx context.Context, timeout time.Duration, call func(context.Context) (T, error)) (T, error) { + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return call(callCtx) +} + +func (c *Client) withTimeoutInto(ctx context.Context, call func(context.Context) error) error { + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + return call(callCtx) +} diff --git a/go-runtime/internal/evm/fakenode_test.go b/go-runtime/internal/evm/fakenode_test.go new file mode 100644 index 0000000..8d68973 --- /dev/null +++ b/go-runtime/internal/evm/fakenode_test.go @@ -0,0 +1,339 @@ +package evm + +import ( + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" +) + +// fakeNode is an in-process JSON-RPC node. It serves exactly the methods the +// runtime uses, and it records every broadcast so a test can prove that a +// resumed action was not sent twice. +type fakeNode struct { + mu sync.Mutex + + chainID uint64 + gasPrice *big.Int + pendingNonce uint64 + head uint64 + + finalizedSupported bool + finalizedNumber uint64 + validatorCount int + + // blocksBeforeAdvance makes the node reveal one further block after that + // many eth_blockNumber probes, which is how the QBFT successor wait + // terminates. + blocksBeforeAdvance int + latestQueries int + + receipts map[common.Hash]map[string]any + mineBroadcasts bool + // mineCreation, when set, is the contract address the node reports for the + // next mined transaction, i.e. a deployment. + mineCreation *common.Address + + broadcasts [][]byte + broadcastError string + + requests map[string]int +} + +type fakeNodeOption func(*fakeNode) + +func withFinalizedTag(number uint64) fakeNodeOption { + return func(node *fakeNode) { + node.finalizedSupported = true + node.finalizedNumber = number + } +} + +func withValidators(count int) fakeNodeOption { + return func(node *fakeNode) { node.validatorCount = count } +} + +func withHead(head uint64, blocksBeforeAdvance int) fakeNodeOption { + return func(node *fakeNode) { + node.head = head + node.blocksBeforeAdvance = blocksBeforeAdvance + } +} + +func withPendingNonce(nonce uint64) fakeNodeOption { + return func(node *fakeNode) { node.pendingNonce = nonce } +} + +func withBroadcastError(message string) fakeNodeOption { + return func(node *fakeNode) { node.broadcastError = message } +} + +func withoutMining() fakeNodeOption { + return func(node *fakeNode) { node.mineBroadcasts = false } +} + +func newFakeNode(t *testing.T, options ...fakeNodeOption) *fakeNode { + t.Helper() + node := &fakeNode{ + chainID: 31337, + gasPrice: big.NewInt(1_500_000_000), + pendingNonce: 5, + head: 7, + validatorCount: 4, + receipts: map[common.Hash]map[string]any{}, + mineBroadcasts: true, + requests: map[string]int{}, + } + for _, option := range options { + option(node) + } + return node +} + +func fakeNodeServer(t *testing.T, node *fakeNode) *httptest.Server { + t.Helper() + server := httptest.NewServer(node) + t.Cleanup(server.Close) + return server +} + +func (n *fakeNode) requestCount(method string) int { + n.mu.Lock() + defer n.mu.Unlock() + return n.requests[method] +} + +func (n *fakeNode) recordedBroadcasts() [][]byte { + n.mu.Lock() + defer n.mu.Unlock() + return append([][]byte(nil), n.broadcasts...) +} + +// seedReceipt registers the receipt a mined transaction would have. +func (n *fakeNode) seedReceipt(hash common.Hash, status uint64, block, gasUsed uint64, contract *common.Address) { + n.mu.Lock() + defer n.mu.Unlock() + n.receipts[hash] = n.buildReceiptLocked(hash, status, block, gasUsed, contract) +} + +func (n *fakeNode) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + defer request.Body.Close() + var call struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + if err := json.NewDecoder(request.Body).Decode(&call); err != nil { + http.Error(writer, "bad request", http.StatusBadRequest) + return + } + n.mu.Lock() + n.requests[call.Method]++ + n.mu.Unlock() + result, rpcError := n.dispatch(call.Method, call.Params) + writer.Header().Set("Content-Type", "application/json") + response := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(call.ID)} + if rpcError != nil { + response["error"] = rpcError + } else { + response["result"] = result + } + _ = json.NewEncoder(writer).Encode(response) +} + +type rpcFailure struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (n *fakeNode) dispatch(method string, params []json.RawMessage) (any, *rpcFailure) { + switch method { + case "eth_chainId": + return hexutil.EncodeUint64(n.chainID), nil + case "eth_blockNumber": + return n.blockNumber() + case "eth_getTransactionCount": + n.mu.Lock() + defer n.mu.Unlock() + return hexutil.EncodeUint64(n.pendingNonce), nil + case "eth_gasPrice": + n.mu.Lock() + defer n.mu.Unlock() + return hexutil.EncodeBig(n.gasPrice), nil + case "eth_sendRawTransaction": + return n.sendRaw(params) + case "eth_getTransactionReceipt": + return n.receipt(params) + case "eth_getBlockByNumber": + return n.block(params) + case "qbft_getValidatorsByBlockNumber": + n.mu.Lock() + defer n.mu.Unlock() + if n.validatorCount == 0 { + return nil, &rpcFailure{Code: -32601, Message: "the method qbft_getValidatorsByBlockNumber does not exist"} + } + validators := make([]string, 0, n.validatorCount) + for index := 0; index < n.validatorCount; index++ { + validators = append(validators, common.BigToAddress(big.NewInt(int64(index)+1)).Hex()) + } + return validators, nil + default: + return nil, &rpcFailure{Code: -32601, Message: fmt.Sprintf("the method %s does not exist", method)} + } +} + +func (n *fakeNode) sendRaw(params []json.RawMessage) (any, *rpcFailure) { + if len(params) != 1 { + return nil, &rpcFailure{Code: -32602, Message: "eth_sendRawTransaction needs one parameter"} + } + var encoded string + if err := json.Unmarshal(params[0], &encoded); err != nil { + return nil, &rpcFailure{Code: -32602, Message: "the raw transaction is not hex"} + } + raw, err := hexutil.Decode(encoded) + if err != nil { + return nil, &rpcFailure{Code: -32602, Message: "the raw transaction is not hex"} + } + hash := crypto.Keccak256Hash(raw) + n.mu.Lock() + n.broadcasts = append(n.broadcasts, raw) + if n.mineBroadcasts { + if _, present := n.receipts[hash]; !present { + n.receipts[hash] = n.buildReceiptLocked(hash, 1, n.head+1, 21_000, n.mineCreation) + } + } + failure := n.broadcastError + n.mu.Unlock() + if failure != "" { + return nil, &rpcFailure{Code: -32000, Message: failure} + } + return hash, nil +} + +func (n *fakeNode) receipt(params []json.RawMessage) (any, *rpcFailure) { + if len(params) != 1 { + return nil, &rpcFailure{Code: -32602, Message: "eth_getTransactionReceipt needs one parameter"} + } + var encoded string + if err := json.Unmarshal(params[0], &encoded); err != nil { + return nil, &rpcFailure{Code: -32602, Message: "the transaction hash is not hex"} + } + n.mu.Lock() + defer n.mu.Unlock() + document, present := n.receipts[common.HexToHash(encoded)] + if !present { + return nil, nil + } + return document, nil +} + +// blockNumber serves eth_blockNumber and is the probe the QBFT successor wait +// polls, so it is what makes the head advance. +func (n *fakeNode) blockNumber() (any, *rpcFailure) { + n.mu.Lock() + defer n.mu.Unlock() + n.latestQueries++ + if n.blocksBeforeAdvance > 0 && n.latestQueries > n.blocksBeforeAdvance { + n.head++ + n.blocksBeforeAdvance = 0 + } + return hexutil.EncodeUint64(n.head), nil +} + +func (n *fakeNode) block(params []json.RawMessage) (any, *rpcFailure) { + if len(params) < 1 { + return nil, &rpcFailure{Code: -32602, Message: "eth_getBlockByNumber needs a block number"} + } + var selector string + if err := json.Unmarshal(params[0], &selector); err != nil { + return nil, &rpcFailure{Code: -32602, Message: "the block selector is not a string"} + } + n.mu.Lock() + defer n.mu.Unlock() + switch selector { + case "finalized": + if !n.finalizedSupported { + // Besu's wording for a tag it does not implement, which is the one + // root_signer._finalized_number tolerates. + return nil, &rpcFailure{Code: -32602, Message: "unknown block"} + } + return n.buildBlockLocked(n.finalizedNumber), nil + case "latest": + return n.buildBlockLocked(n.head), nil + default: + number, err := hexutil.DecodeUint64(selector) + if err != nil { + return nil, &rpcFailure{Code: -32602, Message: "the block selector is not a number"} + } + if number > n.head { + return nil, &rpcFailure{Code: -32602, Message: "unknown block"} + } + return n.buildBlockLocked(number), nil + } +} + +// buildBlockLocked returns a header with the fields go-ethereum requires, derived +// deterministically from the block number so the header hash is stable and +// distinct per height. +func (n *fakeNode) buildBlockLocked(number uint64) map[string]any { + numberHash := common.BigToHash(new(big.Int).SetUint64(number)) + parentHash := common.BigToHash(new(big.Int).SetUint64(number - 1)) + return map[string]any{ + "parentHash": parentHash, + "sha3Uncles": common.Hash{}, + "miner": common.Address{}, + "stateRoot": numberHash, + "transactionsRoot": numberHash, + "receiptsRoot": numberHash, + "logsBloom": hexutil.Bytes(make([]byte, 256)), + "difficulty": "0x1", + "number": hexutil.EncodeUint64(number), + "gasLimit": "0x1c9c380", + "gasUsed": "0x5208", + "timestamp": "0x1", + "extraData": hexutil.Bytes(numberHash.Bytes()), + "mixHash": common.Hash{}, + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x3b9aca00", + "hash": numberHash, + } +} + +func (n *fakeNode) buildReceiptLocked( + hash common.Hash, + status uint64, + block uint64, + gasUsed uint64, + contract *common.Address, +) map[string]any { + blockHash := common.BigToHash(new(big.Int).SetUint64(block)) + document := map[string]any{ + "transactionHash": hash, + "transactionIndex": "0x0", + "blockHash": blockHash, + "blockNumber": hexutil.EncodeUint64(block), + "from": common.BigToAddress(big.NewInt(1)), + "to": common.BigToAddress(big.NewInt(2)), + "cumulativeGasUsed": hexutil.EncodeUint64(gasUsed), + "gasUsed": hexutil.EncodeUint64(gasUsed), + "effectiveGasPrice": "0x3b9aca00", + "contractAddress": nil, + "logs": []any{}, + "logsBloom": hexutil.Bytes(make([]byte, 256)), + "status": hexutil.EncodeUint64(status), + "type": "0x2", + } + if contract != nil { + document["contractAddress"] = strings.ToLower(contract.Hex()) + } + return document +} diff --git a/go-runtime/internal/evm/signer.go b/go-runtime/internal/evm/signer.go new file mode 100644 index 0000000..37efb5c --- /dev/null +++ b/go-runtime/internal/evm/signer.go @@ -0,0 +1,142 @@ +package evm + +import ( + "crypto/ecdsa" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +// personalPrefix is the EIP-191 version 0x45 prefix eth_account uses in +// encode_defunct for a 32 byte primitive. +const personalPrefix = "\x19Ethereum Signed Message:\n32" + +// recoveryOffset converts a raw recovery id into the 27/28 encoding that +// eth_account returns from sign_message and Account.signHash. +const recoveryOffset = 27 + +// Signer signs XIR transactions and digests with one frozen key. +type Signer struct { + key *ecdsa.PrivateKey + address common.Address + chainID *big.Int +} + +// NewSigner parses a private key and binds it to one chain id. The key is never +// echoed in an error, so a failed load cannot leak it into a log. +func NewSigner(privateKeyHex string, chainID *big.Int) (*Signer, error) { + if strings.TrimSpace(privateKeyHex) == "" { + return nil, errors.New("xir evm: empty private key") + } + if chainID == nil || chainID.Sign() <= 0 { + return nil, errors.New("xir evm: a signer needs a positive chain id") + } + raw, err := hex.DecodeString( + strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x"), "0X"), + ) + if err != nil { + return nil, errors.New("xir evm: the private key is not hexadecimal") + } + key, err := crypto.ToECDSA(raw) + if err != nil { + return nil, errors.New("xir evm: the private key is not a valid secp256k1 scalar") + } + return &Signer{key: key, address: crypto.PubkeyToAddress(key.PublicKey), chainID: new(big.Int).Set(chainID)}, nil +} + +// Address returns the signer's public address. +func (s *Signer) Address() common.Address { return s.address } + +// ChainID returns the chain the signer is bound to. +func (s *Signer) ChainID() *big.Int { return new(big.Int).Set(s.chainID) } + +// SignDynamicFee signs an EIP-1559 transaction with the signer's chain id. The +// transaction's chain id must be unset or equal to the signer's. +func (s *Signer) SignDynamicFee(transaction *types.DynamicFeeTx) (*types.Transaction, error) { + if transaction == nil { + return nil, errors.New("xir evm: no transaction to sign") + } + if transaction.ChainID != nil && transaction.ChainID.Cmp(s.chainID) != 0 { + return nil, fmt.Errorf( + "xir evm: transaction chain id %s differs from the signer chain id %s", + transaction.ChainID, s.chainID, + ) + } + frozen := *transaction + frozen.ChainID = new(big.Int).Set(s.chainID) + signed, err := types.SignTx(types.NewTx(&frozen), types.LatestSignerForChainID(s.chainID), s.key) + if err != nil { + return nil, fmt.Errorf("xir evm: cannot sign the transaction: %w", err) + } + return signed, nil +} + +// SignPersonalDigest signs the EIP-191 personal digest of a 32 byte value, which +// is byte-identical to +// eth_account.messages.encode_defunct(primitive=digest) followed by +// Account.sign_message. Every XIR signature in the Python runtime (XIR roots, +// LayerZero DVN instructions) uses this scheme. +func (s *Signer) SignPersonalDigest(digest [32]byte) ([]byte, error) { + prefixed := personalDigestHash(digest) + return s.signHash(prefixed) +} + +// SignRawDigest signs the digest itself, with no EIP-191 prefix. Hyperlane +// checkpoint signatures use this form. +func (s *Signer) SignRawDigest(digest [32]byte) ([]byte, error) { + return s.signHash(digest) +} + +func (s *Signer) signHash(digest [32]byte) ([]byte, error) { + signature, err := crypto.Sign(digest[:], s.key) + if err != nil { + return nil, fmt.Errorf("xir evm: cannot sign the digest: %w", err) + } + signature[64] += recoveryOffset + return signature, nil +} + +// RecoverPersonalDigest recovers the address that signed the EIP-191 personal +// digest of a 32 byte value. +func RecoverPersonalDigest(digest [32]byte, signature []byte) (common.Address, error) { + prefixed := personalDigestHash(digest) + return recoverHash(prefixed, signature) +} + +func personalDigestHash(digest [32]byte) [32]byte { + payload := make([]byte, 0, len(personalPrefix)+len(digest)) + payload = append(payload, personalPrefix...) + payload = append(payload, digest[:]...) + var prefixed [32]byte + copy(prefixed[:], crypto.Keccak256(payload)) + return prefixed +} + +func recoverHash(digest [32]byte, signature []byte) (common.Address, error) { + if len(signature) != crypto.SignatureLength { + return common.Address{}, fmt.Errorf( + "xir evm: signature is %d bytes, want %d", len(signature), crypto.SignatureLength, + ) + } + normalized := make([]byte, crypto.SignatureLength) + copy(normalized, signature) + switch normalized[64] { + case 0, 1: + normalized[64] += recoveryOffset + case 27, 28: + default: + return common.Address{}, fmt.Errorf("xir evm: signature recovery id is %d", signature[64]) + } + normalized[64] -= recoveryOffset + publicKey, err := crypto.SigToPub(digest[:], normalized) + if err != nil { + return common.Address{}, fmt.Errorf("xir evm: cannot recover the signer: %w", err) + } + return crypto.PubkeyToAddress(*publicKey), nil +} diff --git a/go-runtime/internal/evm/signer_test.go b/go-runtime/internal/evm/signer_test.go new file mode 100644 index 0000000..6e4c3bf --- /dev/null +++ b/go-runtime/internal/evm/signer_test.go @@ -0,0 +1,390 @@ +package evm + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// vectorDocument is the subset of testdata/vectors.json this package proves +// parity against: the Python runtime's root signatures over each envelope rid. +type vectorDocument struct { + SchemaVersion string `json:"schema_version"` + Constants struct { + // FixtureKeys holds the signing keys the generator derives from the + // fixed seed; they are only ever used by tests. + FixtureKeys map[string]string `json:"fixture_keys"` + ChainIDs []uint64 `json:"chain_ids"` + } `json:"constants"` + Envelopes []struct { + RID string `json:"rid"` + MID string `json:"mid"` + RootSignature string `json:"root_signature"` + } `json:"envelopes"` +} + +func loadVectors(t *testing.T) vectorDocument { + t.Helper() + path := filepath.Join("..", "..", "testdata", "vectors.json") + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read %s: %v", path, err) + } + var document vectorDocument + if err := json.Unmarshal(contents, &document); err != nil { + t.Fatalf("cannot decode %s: %v", path, err) + } + if len(document.Envelopes) == 0 { + t.Fatalf("%s carries no envelopes", path) + } + return document +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + t.Fatalf("cannot decode %q: %v", value, err) + } + return decoded +} + +// TestSignPersonalDigestMatchesPythonVectors proves byte parity with +// eth_account.messages.encode_defunct(primitive=rid) + +// Account.sign_message(...), which is how the Python runtime signs XIR roots +// (runner.py, root_signer.py, layerzero.py). +func TestSignPersonalDigestMatchesPythonVectors(t *testing.T) { + document := loadVectors(t) + privateKey := document.Constants.FixtureKeys["root_signer"] + if privateKey == "" { + t.Fatal("vectors carry no root_signer fixture key") + } + signer, err := NewSigner(privateKey, big.NewInt(int64(document.Constants.ChainIDs[0]))) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + for index, envelope := range document.Envelopes { + var digest [32]byte + copy(digest[:], mustHex(t, envelope.RID)) + signature, err := signer.SignPersonalDigest(digest) + if err != nil { + t.Fatalf("envelope %d: SignPersonalDigest: %v", index, err) + } + want := mustHex(t, envelope.RootSignature) + if len(signature) != len(want) { + t.Fatalf("envelope %d: signature is %d bytes, want %d", index, len(signature), len(want)) + } + for offset := range signature { + if signature[offset] != want[offset] { + t.Fatalf( + "envelope %d: signature = 0x%x, want 0x%x", + index, signature, want, + ) + } + } + recovered, err := RecoverPersonalDigest(digest, signature) + if err != nil { + t.Fatalf("envelope %d: RecoverPersonalDigest: %v", index, err) + } + if recovered != signer.Address() { + t.Fatalf("envelope %d: recovered %s, want %s", index, recovered, signer.Address()) + } + } +} + +// TestRecoverPersonalDigestNormalizesRecoveryID proves that both the 27/28 +// spelling produced by Solidity tooling and the 0/1 spelling geth's low-level +// recovery expects resolve to the same signer. XIRGateway._recover accepts both +// (`if (v < 27) v += 27;`), so the Go runtime must too. +func TestRecoverPersonalDigestNormalizesRecoveryID(t *testing.T) { + document := loadVectors(t) + signer, err := NewSigner(document.Constants.FixtureKeys["root_signer"], big.NewInt(31337)) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + var digest [32]byte + copy(digest[:], mustHex(t, document.Envelopes[0].RID)) + signature, err := signer.SignPersonalDigest(digest) + if err != nil { + t.Fatalf("SignPersonalDigest: %v", err) + } + ethereumSpelling := append([]byte(nil), signature...) + rawSpelling := append([]byte(nil), signature...) + rawSpelling[64] -= 27 + for name, candidate := range map[string][]byte{ + "27/28": ethereumSpelling, + "0/1": rawSpelling, + } { + recovered, err := RecoverPersonalDigest(digest, candidate) + if err != nil { + t.Fatalf("%s: RecoverPersonalDigest: %v", name, err) + } + if recovered != signer.Address() { + t.Fatalf("%s: recovered %s, want %s", name, recovered, signer.Address()) + } + } + invalid := append([]byte(nil), signature...) + invalid[64] = 7 + if _, err := RecoverPersonalDigest(digest, invalid); err == nil { + t.Fatal("an out-of-range recovery id must be rejected") + } + if _, err := RecoverPersonalDigest(digest, signature[:64]); err == nil { + t.Fatal("a 64 byte signature must be rejected") + } +} + +// TestSignRawDigestSignsTheDigestItself proves SignRawDigest applies no EIP-191 +// prefix (the Hyperlane checkpoint convention) while SignPersonalDigest does. +func TestSignRawDigestSignsTheDigestItself(t *testing.T) { + document := loadVectors(t) + signer, err := NewSigner(document.Constants.FixtureKeys["validator"], big.NewInt(31337)) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + var digest [32]byte + copy(digest[:], mustHex(t, document.Envelopes[0].MID)) + raw, err := signer.SignRawDigest(digest) + if err != nil { + t.Fatalf("SignRawDigest: %v", err) + } + personal, err := signer.SignPersonalDigest(digest) + if err != nil { + t.Fatalf("SignPersonalDigest: %v", err) + } + if string(raw) == string(personal) { + t.Fatal("the raw digest signature must differ from the personal digest signature") + } + // Verify the raw signature against the digest directly, with a different + // code path from the recovery helper. + recovered, err := crypto.SigToPub(digest[:], normalizeRecoveryID(t, raw)) + if err != nil { + t.Fatalf("SigToPub: %v", err) + } + if crypto.PubkeyToAddress(*recovered) != signer.Address() { + t.Fatalf("raw signature recovers %s, want %s", crypto.PubkeyToAddress(*recovered), signer.Address()) + } + prefixed := personalDigestHash(digest) + if !crypto.VerifySignature(crypto.FromECDSAPub(&signer.key.PublicKey), digest[:], normalizeRecoveryID(t, raw)[:64]) { + t.Fatal("the raw signature does not verify against the bare digest") + } + if crypto.VerifySignature(crypto.FromECDSAPub(&signer.key.PublicKey), prefixed[:], normalizeRecoveryID(t, raw)[:64]) { + t.Fatal("the raw signature verifies against the personal digest, so it carries a prefix") + } + if !crypto.VerifySignature(crypto.FromECDSAPub(&signer.key.PublicKey), prefixed[:], normalizeRecoveryID(t, personal)[:64]) { + t.Fatal("the personal signature does not verify against the personal digest") + } + if raw[64] != 27 && raw[64] != 28 { + t.Fatalf("raw digest recovery id = %d, want 27 or 28 (eth_account convention)", raw[64]) + } +} + +func normalizeRecoveryID(t *testing.T, signature []byte) []byte { + t.Helper() + normalized := append([]byte(nil), signature...) + if normalized[64] >= 27 { + normalized[64] -= 27 + } + return normalized +} + +func TestNewSignerRejectsInvalidKeysWithoutEchoingThem(t *testing.T) { + const secret = "0x" + "zz" + _, err := NewSigner(secret, big.NewInt(1)) + if err == nil { + t.Fatal("a non-hexadecimal key must be rejected") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("the error message leaks the key: %v", err) + } + if _, err := NewSigner("0x00", big.NewInt(1)); err == nil { + t.Fatal("a short key must be rejected") + } + if _, err := NewSigner("0x"+strings.Repeat("11", 32), nil); err == nil { + t.Fatal("a nil chain id must be rejected") + } +} + +func dialFake(t *testing.T, node *fakeNode) *Client { + t.Helper() + server := fakeNodeServer(t, node) + client, err := Dial(context.Background(), server.URL, 5*time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(client.Close) + return client +} + +func TestFinalizedNumberPrefersTheFinalizedTag(t *testing.T) { + node := newFakeNode(t, withFinalizedTag(9), withHead(12, 0)) + client := dialFake(t, node) + number, rule, err := client.FinalizedNumber(context.Background()) + if err != nil { + t.Fatalf("FinalizedNumber: %v", err) + } + if number != 9 || rule != "rpc-finalized-tag" { + t.Fatalf("FinalizedNumber = %d, %q; want 9, rpc-finalized-tag", number, rule) + } + if node.requestCount("qbft_getValidatorsByBlockNumber") != 0 { + t.Error("the QBFT fallback must not run when the finalized tag works") + } +} + +// TestFinalizedNumberQBFTFallback reproduces +// root_signer.Web3RootCreationSource._finalized_number on a private QBFT chain: +// the finalized tag is rejected, the validator quorum is read from +// qbft_getValidatorsByBlockNumber, and the head only becomes final once one +// successor block exists and the observed block is still canonical. +func TestFinalizedNumberQBFTFallback(t *testing.T) { + node := newFakeNode(t, withHead(7, 1), withValidators(4)) + client := dialFake(t, node) + number, rule, err := client.FinalizedNumber(context.Background()) + if err != nil { + t.Fatalf("FinalizedNumber: %v", err) + } + if number != 8 { + t.Fatalf("finalized number = %d, want 8 (head 7 plus one successor)", number) + } + if rule != "qbft-committed-plus-1" { + t.Fatalf("finality rule = %q, want qbft-committed-plus-1", rule) + } + if node.requestCount("eth_getBlockByNumber") == 0 || node.requestCount("qbft_getValidatorsByBlockNumber") == 0 { + t.Errorf("requests = %v, want both block and validator lookups", node.requests) + } +} + +func TestFinalizedNumberQBFTRequiresValidatorQuorum(t *testing.T) { + node := newFakeNode(t, withHead(7, 1), withValidators(2)) + client := dialFake(t, node) + _, rule, err := client.FinalizedNumber(context.Background()) + if err == nil { + t.Fatalf("FinalizedNumber = %q, want a quorum failure", rule) + } + if !strings.Contains(err.Error(), "quorum") { + t.Fatalf("error = %v, want a quorum failure", err) + } +} + +func TestFinalizedNumberQBFTRejectsNonCanonicalBlock(t *testing.T) { + node := newFakeNode(t, withHead(8, 0), withValidators(4)) + client := dialFake(t, node) + header, err := client.HeaderByNumber(context.Background(), big.NewInt(7)) + if err != nil { + t.Fatalf("HeaderByNumber: %v", err) + } + number, rule, err := client.FinalizedNumberFor(context.Background(), 7, header.Hash()) + if err != nil { + t.Fatalf("FinalizedNumberFor: %v", err) + } + if number != 8 || rule != "qbft-committed-plus-1" { + t.Fatalf("FinalizedNumberFor = %d, %q; want 8, qbft-committed-plus-1", number, rule) + } + if _, _, err := client.FinalizedNumberFor( + context.Background(), 7, common.HexToHash("0xdead"), + ); err == nil { + t.Fatal("a block outside the canonical chain must be rejected") + } +} + +func TestWaitMinedTimesOutWithoutAReceipt(t *testing.T) { + node := newFakeNode(t, withoutMining()) + client := dialFake(t, node) + _, err := client.WaitMined(context.Background(), common.HexToHash("0x01"), 200*time.Millisecond) + if err == nil { + t.Fatal("WaitMined must fail when no receipt appears") + } + if !strings.Contains(err.Error(), "was not mined") { + t.Fatalf("error = %v, want a not-mined failure", err) + } +} + +func TestReceiptIsNilBeforeMining(t *testing.T) { + node := newFakeNode(t, withoutMining()) + client := dialFake(t, node) + receipt, err := client.Receipt(context.Background(), common.HexToHash("0x01")) + if err != nil { + t.Fatalf("Receipt: %v", err) + } + if receipt != nil { + t.Fatalf("Receipt = %+v, want nil", receipt) + } +} + +type rpcFailureError struct { + code int + message string +} + +func (e rpcFailureError) Error() string { return e.message } +func (e rpcFailureError) ErrorCode() int { return e.code } + +type transportFailure struct{} + +func (transportFailure) Error() string { return "connection reset by peer" } +func (transportFailure) Timeout() bool { return false } +func (transportFailure) Temporary() bool { return true } + +var _ net.Error = transportFailure{} + +func TestIsTransientRPCError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"plain", errors.New("reverted"), false}, + {"transport", transportFailure{}, true}, + {"eof", io.EOF, true}, + {"deadline", context.DeadlineExceeded, true}, + {"cancelled", context.Canceled, false}, + { + "besu catch-up", + rpcFailureError{code: -32000, message: "Transaction pool not enabled. Node not yet in sync."}, + true, + }, + { + "pool disabled only", + rpcFailureError{code: -32000, message: "transaction pool not enabled"}, + false, + }, + {"insufficient funds", rpcFailureError{code: -32000, message: "insufficient funds for gas * price + value"}, false}, + } + for _, testCase := range cases { + if observed := IsTransientRPCError(testCase.err); observed != testCase.want { + t.Errorf("%s: IsTransientRPCError = %v, want %v", testCase.name, observed, testCase.want) + } + } + priorSubmission := rpcFailureError{code: -32000, message: "already known"} + if !IsAcceptedPriorSubmission(priorSubmission) { + t.Error("'already known' must be accepted as a prior submission") + } + if IsTransientRPCError(priorSubmission) { + t.Error("a prior submission is not a transient transport failure") + } + for _, message := range []string{ + "already known", + "ALREADY KNOWN transaction", + "known transaction: 0xabc", + "nonce too low: next nonce 7, tx nonce 5", + } { + if !IsAcceptedPriorSubmission(rpcFailureError{code: -32000, message: message}) { + t.Errorf("%q must be accepted as a prior submission", message) + } + } + if IsAcceptedPriorSubmission(errors.New("insufficient funds")) { + t.Error("a funding failure is not a prior submission") + } +} diff --git a/go-runtime/internal/evm/transactor.go b/go-runtime/internal/evm/transactor.go new file mode 100644 index 0000000..818909c --- /dev/null +++ b/go-runtime/internal/evm/transactor.go @@ -0,0 +1,1001 @@ +package evm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +// defaultTransactionTimeout is the receipt wait the Python deployer uses +// (NativeApplicationDeployer._send passes timeout=180). +const defaultTransactionTimeout = 180 * time.Second + +// gasCeiling is the fallback gas limit deployer.py uses when eth_estimateGas +// cannot be answered. +const gasCeiling = 29_000_000 + +// estimatedGasFloor is the lower bound deployer.py applies to an estimate. +const estimatedGasFloor = 100_000 + +// gasEstimateNumerator/Denominator reproduce deployer.py's 30% headroom +// (estimated * 13 // 10). +const ( + gasEstimateNumerator = 13 + gasEstimateDenominator = 10 +) + +// detailMaxFeePerGas and detailMaxPriorityFeePerGas are the keys under which the +// transactor freezes the EIP-1559 fees inside the action detail. They are frozen +// with the calldata so that re-signing a durable intent after a restart +// reproduces the identical raw transaction. +const ( + detailMaxFeePerGas = "max_fee_per_gas" + detailMaxPriorityFeePerGas = "max_priority_fee_per_gas" +) + +// detailContractAddress is the detail key holding a creation transaction's +// deployed address. +const detailContractAddress = "contract_address" + +// TxRequest is one transaction the runtime wants on chain. +type TxRequest struct { + // ActionID identifies the durable action. It must be stable across restarts + // because it is the ledger key the frozen intent is replayed under. + ActionID string + // IntentDetail freezes caller metadata before signing (under a separate namespace). + IntentDetail map[string]any + // AttemptID and Stage bind the action to the runner attempt ledger. When both + // are set, the signed transaction is also mirrored into the Python runner's + // stages/stage_history/durable_signed_transactions tables. + AttemptID string + Stage string + // ChainRole is the topology role the action belongs to ("a".."e", or + // "deployment" for deployment actions). + ChainRole string + // To is the destination; the zero address means the transaction creates a + // contract, in which case DeployCode supplies the init code. + To common.Address + // Data is the calldata. It must digest to the digest the ledger froze. + Data []byte + // Value is the transferred wei; nil means zero. + Value *big.Int + // Gas is the gas limit. Zero asks the node for an estimate and applies + // deployer.py's headroom rule; multihop_runner's fixed 12_000_000 gas is + // available by passing it explicitly. + Gas uint64 + // DeployCode is the init code of a contract creation. + DeployCode []byte +} + +// Transactor submits transactions through one client and one signer while +// keeping every step durable in the store. +type Transactor struct { + client *Client + signer *Signer + store *state.Store + spoolRoot string + timeout time.Duration + + nonceMu sync.Mutex + nonceNext map[string]uint64 + nonceSeen map[string]bool +} + +// NewTransactor builds a transactor. rawSpoolRoot receives the raw signed +// transactions and the receipt documents; it is created with mode 0700 and every +// file is written with mode 0600 because the raw bytes reveal the signed +// transaction before it is mined. +func NewTransactor( + client *Client, + signer *Signer, + store *state.Store, + rawSpoolRoot string, + transactionTimeout time.Duration, +) (*Transactor, error) { + if client == nil || signer == nil || store == nil { + return nil, errors.New("xir evm: a transactor needs a client, a signer and a store") + } + if strings.TrimSpace(rawSpoolRoot) == "" { + return nil, errors.New("xir evm: a transactor needs a private raw spool root") + } + absolute, err := filepath.Abs(rawSpoolRoot) + if err != nil { + return nil, fmt.Errorf("xir evm: cannot resolve the raw spool root: %w", err) + } + if err := os.MkdirAll(absolute, 0o700); err != nil { + return nil, fmt.Errorf("xir evm: cannot create the raw spool root: %w", err) + } + if err := os.Chmod(absolute, 0o700); err != nil { + return nil, fmt.Errorf("xir evm: cannot restrict the raw spool root: %w", err) + } + if transactionTimeout <= 0 { + transactionTimeout = defaultTransactionTimeout + } + // A signer bound to a different chain than the endpoint is a silent + // catastrophe: every signature would be invalid there. + observed, err := client.ChainID(context.Background()) + if err != nil { + return nil, err + } + if observed.Cmp(signer.ChainID()) != 0 { + return nil, fmt.Errorf( + "xir evm: the endpoint reports chain id %s but the signer is bound to %s", + observed, signer.ChainID(), + ) + } + return &Transactor{ + client: client, + signer: signer, + store: store, + spoolRoot: absolute, + timeout: transactionTimeout, + nonceNext: map[string]uint64{}, + nonceSeen: map[string]bool{}, + }, nil +} + +// SpoolRoot returns the absolute private raw spool root. Callers record it as +// evidence rather than re-deriving it, because the transactor is what resolved +// and restricted it. +func (t *Transactor) SpoolRoot() string { return t.spoolRoot } + +// ActionID derives the action identifier the Python deployer uses: +// sha256("{role}:{nonce}:{action}:{sha256(calldata)}"). Keeping the Go runtime on +// the same derivation means a deployment document produced by either runtime +// refers to the same action for the same intent. +func ActionID(chainRole string, nonce uint64, action string, data []byte) string { + digest := sha256.Sum256(data) + seed := fmt.Sprintf("%s:%d:%s:%s", chainRole, nonce, action, hex.EncodeToString(digest[:])) + identifier := sha256.Sum256([]byte(seed)) + return hex.EncodeToString(identifier[:]) +} + +// ReserveNonce reserves the next account nonce for the default chain role. +func (t *Transactor) ReserveNonce(ctx context.Context) (uint64, error) { + return t.reserveNonce(ctx, "") +} + +// reserveNonce reserves the next account nonce for one chain role. The counter is +// seeded lazily from the chain's pending nonce and from the highest nonce the +// ledger already holds for the chain, so a signed transaction that never reached +// the network cannot have its nonce handed out again. +func (t *Transactor) reserveNonce(ctx context.Context, chainRole string) (uint64, error) { + t.nonceMu.Lock() + defer t.nonceMu.Unlock() + if !t.nonceSeen[chainRole] { + pending, err := t.client.PendingNonce(ctx, t.signer.Address()) + if err != nil { + return 0, err + } + seed := pending + durable, found, err := t.store.MaxActionNonce(t.signer.ChainID().Uint64(), t.senderHex()) + if err != nil { + return 0, err + } + if found && durable+1 > seed { + seed = durable + 1 + } + t.nonceNext[chainRole] = seed + t.nonceSeen[chainRole] = true + } + nonce := t.nonceNext[chainRole] + t.nonceNext[chainRole] = nonce + 1 + return nonce, nil +} + +// senderHex is the lowercase hex address the signer signs as; it is the account +// key the nonce reservation and the ledger floor are scoped to. +func (t *Transactor) senderHex() string { + return strings.ToLower(t.signer.Address().Hex()) +} + +// Call performs a read-only eth_call. +func (t *Transactor) Call(ctx context.Context, to common.Address, data []byte) ([]byte, error) { + return t.client.CallContract(ctx, to, data) +} + +// Execute drives one transaction from frozen intent to durable receipt. +// +// The order is fixed by the durability contract: the intent (including the +// calldata digest, the reserved nonce and the fee caps) is committed before +// signing, the raw transaction is committed and spooled before broadcasting, and +// the receipt document and its digest are committed before the action is marked +// succeeded. A restart therefore either replays the identical signed bytes or +// finds the receipt the previous process already paid for. +func (t *Transactor) Execute(ctx context.Context, request TxRequest) (state.ActionResult, error) { + if err := validateTxRequest(request); err != nil { + return state.ActionResult{}, err + } + var ( + data []byte + value *big.Int + creation bool + ) + action, err := t.store.Action(request.ActionID) + if err != nil { + return state.ActionResult{}, err + } + if action == nil { + // A fresh action: the request is the only source of the intent, so it has + // to name a destination or carry deployment code. + data, creation, err = requestCalldata(request) + if err != nil { + return state.ActionResult{}, err + } + value = request.Value + if value == nil { + value = new(big.Int) + } + action, err = t.freezeIntent(ctx, request, data, value, creation) + if err != nil { + return state.ActionResult{}, err + } + } else { + // A durable action: the ledger is authoritative and the request may be as + // small as the action id. + if err := state.VerifyActionIdentity(action); err != nil { + return state.ActionResult{}, err + } + data, creation, value, err = frozenRequest(request, action, t.senderHex()) + if err != nil { + return state.ActionResult{}, err + } + if action.State == state.ActionFailed { + return state.ActionResult{}, fmt.Errorf( + "xir evm: action %s already failed on chain and cannot be resubmitted", + action.ActionID, + ) + } + if action.State == state.ActionSucceeded { + return t.finishedResult(action) + } + } + raw, transactionHash, err := t.ensureSigned(action, data, value, creation) + if err != nil { + return state.ActionResult{}, err + } + if err := t.validateSignedBytes(action, raw, transactionHash, data, value, creation); err != nil { + return state.ActionResult{}, err + } + // A receipt that already exists means a previous process paid for this + // transaction; resuming must not broadcast a second time. + rawDocument, receipt, err := t.client.ReceiptDocument(ctx, transactionHash) + if err != nil { + return state.ActionResult{}, err + } + if receipt == nil { + if err := t.broadcast(ctx, raw); err != nil { + return state.ActionResult{}, err + } + if err := t.store.Observe(request.ActionID, state.ActionSubmitted, map[string]any{ + "transaction_hash": transactionHash.Hex(), + "raw_sha256": sha256Hex(raw), + }); err != nil { + return state.ActionResult{}, err + } + rawDocument, receipt, err = t.awaitReceipt(ctx, transactionHash) + if err != nil { + return state.ActionResult{}, err + } + } else if err := t.store.Observe(request.ActionID, state.ActionSubmitted, map[string]any{ + "transaction_hash": transactionHash.Hex(), + "raw_sha256": sha256Hex(raw), + }); err != nil { + return state.ActionResult{}, err + } + spooled, err := t.spoolReceipt(transactionHash, rawDocument) + if err != nil { + return state.ActionResult{}, err + } + result := state.ActionResult{ + TransactionHash: transactionHash.Hex(), + BlockNumber: receipt.BlockNumber.Uint64(), + GasUsed: receipt.GasUsed, + ReceiptPath: spooled.path, + ReceiptSHA256: spooled.digest, + Detail: t.resultDetail(action, spooled, receipt, data, creation), + } + if creation { + result.ContractAddress = receipt.ContractAddress.Hex() + } + if receipt.Status != types.ReceiptStatusSuccessful { + if err := t.store.Observe(request.ActionID, state.ActionFailed, result.Detail); err != nil { + return state.ActionResult{}, err + } + return state.ActionResult{}, fmt.Errorf( + "xir evm: transaction %s reverted in block %d", + result.TransactionHash, result.BlockNumber, + ) + } + if err := t.store.Succeed(request.ActionID, result); err != nil { + return state.ActionResult{}, err + } + return result, nil +} + +func validateTxRequest(request TxRequest) error { + if strings.TrimSpace(request.ActionID) == "" { + return errors.New("xir evm: a transaction request needs an action id") + } + return nil +} + +// requestCalldata resolves the calldata and reports whether the request creates a +// contract. A creation carries init code and no destination; every other action +// carries a destination, whose calldata may legitimately be empty (a plain value +// transfer). +func requestCalldata(request TxRequest) ([]byte, bool, error) { + creation := len(request.DeployCode) > 0 + if !creation { + if request.To == (common.Address{}) { + return nil, false, fmt.Errorf( + "xir evm: action %s names neither a destination nor deployment code", + request.ActionID, + ) + } + return request.Data, false, nil + } + if request.To != (common.Address{}) { + return nil, false, fmt.Errorf( + "xir evm: action %s sets both a destination and deployment code", + request.ActionID, + ) + } + if len(request.Data) > 0 { + return nil, false, fmt.Errorf( + "xir evm: action %s sets both calldata and deployment code", + request.ActionID, + ) + } + return request.DeployCode, true, nil +} + +// freezeIntent commits the intent before anything is signed: reserved nonce, gas +// limit, calldata digest, value and the frozen fee caps. +func (t *Transactor) freezeIntent( + ctx context.Context, + request TxRequest, + data []byte, + value *big.Int, + creation bool, +) (*state.Action, error) { + nonce, err := t.reserveNonce(ctx, request.ChainRole) + if err != nil { + return nil, err + } + gas, err := t.resolveGas(ctx, request, data, value, creation) + if err != nil { + return nil, err + } + maxFee, maxPriority, err := t.resolveFees(ctx) + if err != nil { + return nil, err + } + target := "" + if !creation { + target = strings.ToLower(request.To.Hex()) + } + detail := map[string]any{ + "role": request.ChainRole, + "nonce": nonce, + "target": target, + "calldata_sha256": sha256Hex(data), + "calldata_bytes": len(data), + "chain_id": t.signer.ChainID().Uint64(), + detailMaxFeePerGas: maxFee.String(), + detailMaxPriorityFeePerGas: maxPriority.String(), + } + if request.IntentDetail != nil { + detail["intent_detail"] = request.IntentDetail + } + if request.Stage != "" { + detail["stage"] = request.Stage + } + detailJSON, err := state.CanonicalJSON(detail) + if err != nil { + return nil, err + } + return t.store.Intend(state.Action{ + ActionID: request.ActionID, + AttemptID: request.AttemptID, + Stage: request.Stage, + ChainRole: request.ChainRole, + ChainID: t.signer.ChainID().Uint64(), + Sender: t.senderHex(), + To: target, + CalldataHex: "0x" + hex.EncodeToString(data), + CalldataBytes: len(data), + CalldataSHA256: sha256Hex(data), + Value: value.String(), + Gas: gas, + Nonce: nonce, + State: state.ActionIntended, + DetailJSON: detailJSON, + }) +} + +// resolveGas applies deployer.py's estimate rule: a 30% margin over the node's +// estimate, floored at 100_000 and capped at 29_000_000, with the ceiling as the +// fallback when the estimate cannot be produced. +func (t *Transactor) resolveGas( + ctx context.Context, + request TxRequest, + data []byte, + value *big.Int, + creation bool, +) (uint64, error) { + if request.Gas != 0 { + return request.Gas, nil + } + message := ethereum.CallMsg{From: t.signer.Address(), Data: data, Value: value} + if !creation { + to := request.To + message.To = &to + } + estimated, err := t.client.EstimateGas(ctx, message) + if err != nil { + if IsTransientRPCError(err) { + return 0, err + } + return gasCeiling, nil + } + scaled := estimated * gasEstimateNumerator / gasEstimateDenominator + if scaled < estimatedGasFloor { + scaled = estimatedGasFloor + } + if scaled > gasCeiling { + scaled = gasCeiling + } + return scaled, nil +} + +// resolveFees reproduces multihop_runner's fee choice: maxFeePerGas is twice the +// suggested gas price (never below 1) and the priority fee is zero. +func (t *Transactor) resolveFees(ctx context.Context) (*big.Int, *big.Int, error) { + price, err := t.client.GasPrice(ctx) + if err != nil { + return nil, nil, err + } + maxFee := new(big.Int).Mul(price, big.NewInt(2)) + if maxFee.Sign() <= 0 { + maxFee = big.NewInt(1) + } + return maxFee, new(big.Int), nil +} + +// frozenRequest resolves the calldata, the value and the creation flag of a +// durable action from the ledger, and rejects a request whose supplied fields +// disagree with the frozen intent. A resumed action may be requested with the +// action id alone. +func frozenRequest(request TxRequest, action *state.Action, sender string) ([]byte, bool, *big.Int, error) { + drift := func(format string, arguments ...any) ([]byte, bool, *big.Int, error) { + return nil, false, nil, fmt.Errorf("%w: %s", state.ErrDrift, fmt.Sprintf(format, arguments...)) + } + data, err := decodeHex(action.CalldataHex) + if err != nil { + return drift("action %s carries undecodable calldata: %v", action.ActionID, err) + } + value, ok := new(big.Int).SetString(action.Value, 10) + if !ok { + return drift("action %s froze value %q", action.ActionID, action.Value) + } + if request.IntentDetail != nil { + detail, err := action.Detail() + if err != nil { + return nil, false, nil, err + } + stored, err := state.CanonicalJSON(detail["intent_detail"]) + if err != nil { + return nil, false, nil, err + } + supplied, err := state.CanonicalJSON(request.IntentDetail) + if err != nil { + return nil, false, nil, err + } + if stored != supplied { + return drift("action %s caller intent metadata changed", action.ActionID) + } + } + creation := action.To == "" + if action.Sender != "" && action.Sender != sender { + return drift( + "action %s belongs to account %s but this transactor signs as %s", + action.ActionID, action.Sender, sender, + ) + } + if request.ChainRole != "" && request.ChainRole != action.ChainRole { + return drift( + "action %s belongs to chain role %s but the request says %s", + action.ActionID, action.ChainRole, request.ChainRole, + ) + } + if request.AttemptID != "" && action.AttemptID != "" && request.AttemptID != action.AttemptID { + return drift( + "action %s belongs to attempt %s but the request says %s", + action.ActionID, action.AttemptID, request.AttemptID, + ) + } + if request.Stage != "" && action.Stage != "" && request.Stage != action.Stage { + return drift( + "action %s belongs to stage %s but the request says %s", + action.ActionID, action.Stage, request.Stage, + ) + } + if len(request.Data) > 0 && sha256Hex(request.Data) != action.CalldataSHA256 { + return drift( + "action %s froze calldata digest %s but the request carries %s", + action.ActionID, action.CalldataSHA256, sha256Hex(request.Data), + ) + } + if len(request.DeployCode) > 0 { + if !creation { + return drift("action %s froze a destination but the request carries deployment code", action.ActionID) + } + if sha256Hex(request.DeployCode) != action.CalldataSHA256 { + return drift( + "action %s froze calldata digest %s but the request carries %s", + action.ActionID, action.CalldataSHA256, sha256Hex(request.DeployCode), + ) + } + } + if request.To != (common.Address{}) { + if creation { + return drift("action %s created a contract but the request names a destination", action.ActionID) + } + if strings.ToLower(request.To.Hex()) != action.To { + return drift( + "action %s froze target %q but the request names %q", + action.ActionID, action.To, strings.ToLower(request.To.Hex()), + ) + } + } + if request.Value != nil && request.Value.String() != action.Value { + return drift( + "action %s froze value %s but the request carries %s", + action.ActionID, action.Value, request.Value.String(), + ) + } + if request.Gas != 0 && request.Gas != action.Gas { + return drift( + "action %s froze gas %d but the request carries %d", + action.ActionID, action.Gas, request.Gas, + ) + } + return data, creation, value, nil +} + +// ensureSigned returns the raw signed bytes for the action, restoring them from +// the ledger when they are already durable and signing the frozen intent +// otherwise. +func (t *Transactor) ensureSigned( + action *state.Action, + data []byte, + value *big.Int, + creation bool, +) ([]byte, common.Hash, error) { + if action.RawTransactionHex != "" { + raw, err := hex.DecodeString(strings.TrimPrefix(action.RawTransactionHex, "0x")) + if err != nil { + return nil, common.Hash{}, fmt.Errorf( + "%w: action %s has undecodable raw bytes: %v", + state.ErrDrift, action.ActionID, err, + ) + } + return raw, common.HexToHash(action.TransactionHash), nil + } + detail, err := action.Detail() + if err != nil { + return nil, common.Hash{}, err + } + maxFee, err := detailBigInt(detail, detailMaxFeePerGas, action.ActionID) + if err != nil { + return nil, common.Hash{}, err + } + maxPriority, err := detailBigInt(detail, detailMaxPriorityFeePerGas, action.ActionID) + if err != nil { + return nil, common.Hash{}, err + } + transaction := &types.DynamicFeeTx{ + ChainID: t.signer.ChainID(), + Nonce: action.Nonce, + GasTipCap: maxPriority, + GasFeeCap: maxFee, + Gas: action.Gas, + Value: value, + Data: data, + } + if !creation { + to := common.HexToAddress(action.To) + transaction.To = &to + } + signed, err := t.signer.SignDynamicFee(transaction) + if err != nil { + return nil, common.Hash{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Hash{}, fmt.Errorf("xir evm: cannot encode the signed transaction: %w", err) + } + if err := t.writePrivateRaw(transactionHashPath(t.spoolRoot, signed.Hash()), raw); err != nil { + return nil, common.Hash{}, err + } + if err := t.store.RecordSigned(action.ActionID, raw, signed.Hash().Hex()); err != nil { + return nil, common.Hash{}, err + } + return raw, signed.Hash(), nil +} + +func detailBigInt(detail map[string]any, key, actionID string) (*big.Int, error) { + value, ok := detail[key] + if !ok { + return nil, fmt.Errorf( + "xir evm: action %s has no frozen %s, so its intent cannot be replayed", + actionID, key, + ) + } + text, ok := value.(string) + if !ok { + return nil, fmt.Errorf("xir evm: action %s froze %s as %T", actionID, key, value) + } + parsed, ok := new(big.Int).SetString(text, 10) + if !ok { + return nil, fmt.Errorf("xir evm: action %s froze %s as %q", actionID, key, text) + } + return parsed, nil +} + +// validateSignedBytes re-derives the signed transaction's identity from its raw +// bytes, mirroring multihop_runner._validate_durable_raw: the recovered signer, +// the chain id, the nonce, the target, the calldata digest and the transaction +// hash all have to agree with the frozen ledger row. +func (t *Transactor) validateSignedBytes( + action *state.Action, + raw []byte, + transactionHash common.Hash, + data []byte, + value *big.Int, + creation bool, +) error { + if observed := crypto.Keccak256Hash(raw); observed != transactionHash { + return fmt.Errorf( + "%w: action %s raw bytes hash to %s but were recorded as %s", + state.ErrDrift, action.ActionID, observed, transactionHash, + ) + } + signer := types.LatestSignerForChainID(t.signer.ChainID()) + var transaction types.Transaction + if err := transaction.UnmarshalBinary(raw); err != nil { + return fmt.Errorf("%w: action %s raw bytes are not a transaction: %v", state.ErrDrift, action.ActionID, err) + } + decoded := &transaction + recovered, err := types.Sender(signer, decoded) + if err != nil { + return fmt.Errorf("%w: action %s signature cannot be recovered: %v", state.ErrDrift, action.ActionID, err) + } + if recovered != t.signer.Address() { + return fmt.Errorf( + "%w: action %s was signed by %s, not %s", + state.ErrDrift, action.ActionID, recovered, t.signer.Address(), + ) + } + if action.Sender != "" && action.Sender != strings.ToLower(recovered.Hex()) { + return fmt.Errorf( + "%w: action %s records account %s but its signature recovers %s", + state.ErrDrift, action.ActionID, action.Sender, recovered, + ) + } + if decoded.ChainId().Cmp(t.signer.ChainID()) != 0 || + decoded.ChainId().Cmp(new(big.Int).SetUint64(action.ChainID)) != 0 { + return fmt.Errorf( + "%w: action %s is signed for chain id %s, not %s (frozen %d)", + state.ErrDrift, action.ActionID, decoded.ChainId(), t.signer.ChainID(), action.ChainID, + ) + } + if decoded.Nonce() != action.Nonce { + return fmt.Errorf( + "%w: action %s is signed with nonce %d but froze %d", + state.ErrDrift, action.ActionID, decoded.Nonce(), action.Nonce, + ) + } + if decoded.Gas() != action.Gas { + return fmt.Errorf( + "%w: action %s is signed with gas %d but froze %d", + state.ErrDrift, action.ActionID, decoded.Gas(), action.Gas, + ) + } + if decoded.Value().Cmp(value) != 0 { + return fmt.Errorf( + "%w: action %s is signed for value %s but froze %s", + state.ErrDrift, action.ActionID, decoded.Value(), value, + ) + } + frozenData := decoded.Data() + if sha256Hex(frozenData) != action.CalldataSHA256 || len(frozenData) != action.CalldataBytes { + return fmt.Errorf( + "%w: action %s is signed with calldata digest %s but froze %s", + state.ErrDrift, action.ActionID, sha256Hex(frozenData), action.CalldataSHA256, + ) + } + frozenTarget := "" + if to := decoded.To(); to != nil { + if creation { + return fmt.Errorf( + "%w: action %s is a deployment but its signed transaction names a destination", + state.ErrDrift, action.ActionID, + ) + } + frozenTarget = strings.ToLower(to.Hex()) + } else if !creation { + return fmt.Errorf( + "%w: action %s is signed as a deployment but the ledger froze a destination", + state.ErrDrift, action.ActionID, + ) + } + if frozenTarget != action.To { + return fmt.Errorf( + "%w: action %s is signed for target %q but froze %q", + state.ErrDrift, action.ActionID, frozenTarget, action.To, + ) + } + return nil +} + +// broadcast sends the raw bytes, accepting the three node answers that mean the +// transaction was already submitted (matching multihop_runner._transact). +func (t *Transactor) broadcast(ctx context.Context, raw []byte) error { + err := t.client.SendRawTransaction(ctx, raw) + if err == nil { + return nil + } + if IsAcceptedPriorSubmission(err) { + return nil + } + return err +} + +// IsAcceptedPriorSubmission reports whether a broadcast failure means the node +// already holds this transaction: "already known", "known transaction", or +// "nonce too low" (the broadcast that consumed the nonce succeeded). +func IsAcceptedPriorSubmission(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + for _, token := range []string{"already known", "known transaction", "nonce too low"} { + if strings.Contains(message, token) { + return true + } + } + return false +} + +func (t *Transactor) awaitReceipt(ctx context.Context, hash common.Hash) (json.RawMessage, *types.Receipt, error) { + if _, err := t.client.WaitMined(ctx, hash, t.timeout); err != nil { + return nil, nil, err + } + return t.client.ReceiptDocument(ctx, hash) +} + +// resultDetail reproduces the detail document multihop_runner._persist_receipt +// merges into the succeeded stage: the frozen intent fields plus the receipt +// evidence. +func (t *Transactor) resultDetail( + action *state.Action, + spooled spooledReceipt, + receipt *types.Receipt, + data []byte, + creation bool, +) map[string]any { + return map[string]any{ + "role": action.ChainRole, + "nonce": action.Nonce, + "target": action.To, + "calldata_sha256": sha256Hex(data), + "calldata_bytes": len(data), + "raw_sha256": actionRawSHA256(action), + "transaction_hash": receipt.TxHash.Hex(), + "receipt": spooled.path, + "receipt_sha256": spooled.digest, + "gas_used": receipt.GasUsed, + "block_number": receipt.BlockNumber.Uint64(), + "chain_id": action.ChainID, + "creation": creation, + detailContractAddress: contractAddressDetail(receipt, creation), + } +} + +func actionRawSHA256(action *state.Action) string { + raw, err := hex.DecodeString(strings.TrimPrefix(action.RawTransactionHex, "0x")) + if err != nil { + return "" + } + return sha256Hex(raw) +} + +func contractAddressDetail(receipt *types.Receipt, creation bool) any { + if !creation { + return nil + } + return receipt.ContractAddress.Hex() +} + +// finishedResult rebuilds the result of an action that already succeeded, +// verifying the spooled receipt bytes before they are trusted again. +func (t *Transactor) finishedResult(action *state.Action) (state.ActionResult, error) { + detail, err := action.Detail() + if err != nil { + return state.ActionResult{}, err + } + if action.ReceiptPath != "" { + contents, err := os.ReadFile(action.ReceiptPath) + if err != nil { + return state.ActionResult{}, fmt.Errorf( + "%w: action %s spooled its receipt at %s: %v", + state.ErrDrift, action.ActionID, action.ReceiptPath, err, + ) + } + if observed := sha256Hex(contents); observed != action.ReceiptSHA256 { + return state.ActionResult{}, fmt.Errorf( + "%w: action %s receipt %s digests to %s, not %s", + state.ErrDrift, action.ActionID, action.ReceiptPath, observed, action.ReceiptSHA256, + ) + } + } + result := state.ActionResult{ + TransactionHash: action.TransactionHash, + BlockNumber: action.BlockNumber, + GasUsed: action.GasUsed, + ReceiptPath: action.ReceiptPath, + ReceiptSHA256: action.ReceiptSHA256, + Detail: detail, + } + if address, ok := detail[detailContractAddress].(string); ok { + result.ContractAddress = address + } + return result, nil +} + +// spooledReceipt is one materialized receipt document. +type spooledReceipt struct { + path string + digest string +} + +// spoolReceipt writes the node's receipt JSON into the private raw spool and +// returns its path and sha256. The document is rendered the way the Python +// runner renders it: sorted keys, two-space indent, trailing newline. +func (t *Transactor) spoolReceipt(hash common.Hash, document json.RawMessage) (spooledReceipt, error) { + if isJSONNull(document) { + return spooledReceipt{}, fmt.Errorf("xir evm: transaction %s has no receipt document", hash) + } + decoded, err := state.DecodeJSON(document) + if err != nil { + return spooledReceipt{}, fmt.Errorf("xir evm: receipt %s is not decodable: %w", hash, err) + } + rendered, err := state.CanonicalJSONIndent(decoded, 2) + if err != nil { + return spooledReceipt{}, err + } + path := transactionReceiptPath(t.spoolRoot, hash) + contents := []byte(rendered + "\n") + if err := t.writePrivateRaw(path, contents); err != nil { + return spooledReceipt{}, err + } + return spooledReceipt{path: path, digest: sha256Hex(contents)}, nil +} + +// writePrivateRaw materializes one private file durably, mirroring +// multihop_runner._write_private_raw_durably: an exclusive temporary in the same +// directory, fsynced and mode 0600, renamed over the target, then the directory +// itself fsynced. Re-writing identical bytes is a no-op; different bytes at the +// same path are drift. +func (t *Transactor) writePrivateRaw(path string, raw []byte) error { + if existing, err := os.ReadFile(path); err == nil { + if !bytesEqual(existing, raw) { + return fmt.Errorf("%w: %s already holds different bytes", state.ErrDrift, path) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("xir evm: cannot read %s: %w", path, err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("xir evm: cannot create a temporary spool file: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return fmt.Errorf("xir evm: cannot restrict %s: %w", temporaryName, err) + } + if _, err := temporary.Write(raw); err != nil { + temporary.Close() + return fmt.Errorf("xir evm: cannot write %s: %w", temporaryName, err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return fmt.Errorf("xir evm: cannot flush %s: %w", temporaryName, err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("xir evm: cannot close %s: %w", temporaryName, err) + } + if err := os.Rename(temporaryName, path); err != nil { + return fmt.Errorf("xir evm: cannot publish %s: %w", path, err) + } + directory, err := os.Open(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("xir evm: cannot open the spool directory: %w", err) + } + defer directory.Close() + if err := directory.Sync(); err != nil { + return fmt.Errorf("xir evm: cannot flush the spool directory: %w", err) + } + return nil +} + +// transactionHashPath is the raw signed-transaction path, matching +// multihop_runner's ".raw". +func transactionHashPath(root string, hash common.Hash) string { + return filepath.Join(root, canonicalHashHex(hash)+".raw") +} + +// transactionReceiptPath is the receipt path, matching +// multihop_runner._persist_receipt's ".json". +func transactionReceiptPath(root string, hash common.Hash) string { + return filepath.Join(root, canonicalHashHex(hash)+".json") +} + +func canonicalHashHex(hash common.Hash) string { + return hex.EncodeToString(hash.Bytes()) +} + +// decodeHex decodes a 0x-prefixed hex string; an empty string decodes to no +// bytes, which is how a value transfer carries its calldata. +func decodeHex(value string) ([]byte, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + trimmed = strings.TrimPrefix(strings.TrimPrefix(trimmed, "0x"), "0X") + if len(trimmed)%2 != 0 { + return nil, fmt.Errorf("hex string %q has an odd number of digits", value) + } + decoded, err := hex.DecodeString(strings.ToLower(trimmed)) + if err != nil { + return nil, fmt.Errorf("hex string %q is not hexadecimal: %w", value, err) + } + return decoded, nil +} + +func sha256Hex(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/go-runtime/internal/evm/transactor_test.go b/go-runtime/internal/evm/transactor_test.go new file mode 100644 index 0000000..929c6f6 --- /dev/null +++ b/go-runtime/internal/evm/transactor_test.go @@ -0,0 +1,794 @@ +package evm + +import ( + "context" + "database/sql" + "encoding/hex" + "errors" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + _ "modernc.org/sqlite" + + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +const testPrivateKey = "0x1111111111111111111111111111111111111111111111111111111111111111" + +type harness struct { + node *fakeNode + client *Client + signer *Signer + store *state.Store + transactor *Transactor + spoolRoot string +} + +// withTransactionTimeout shortens the receipt wait so a test that expects a +// missing receipt does not spend the production timeout. +func withTransactionTimeout(timeout time.Duration) harnessOption { + return func(configuration *harnessConfiguration) { configuration.timeout = timeout } +} + +type harnessConfiguration struct { + timeout time.Duration +} + +type harnessOption func(*harnessConfiguration) + +func newHarness(t *testing.T, options ...any) *harness { + t.Helper() + configuration := harnessConfiguration{timeout: 5 * time.Second} + var nodeOptions []fakeNodeOption + for _, option := range options { + switch typed := option.(type) { + case harnessOption: + typed(&configuration) + case fakeNodeOption: + nodeOptions = append(nodeOptions, typed) + default: + t.Fatalf("unsupported harness option %T", option) + } + } + node := newFakeNode(t, nodeOptions...) + client := dialFake(t, node) + signer, err := NewSigner(testPrivateKey, big.NewInt(int64(node.chainID))) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + store, err := state.Open(filepath.Join(t.TempDir(), "runner", "runner.sqlite")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Errorf("store.Close: %v", err) + } + }) + spoolRoot := filepath.Join(t.TempDir(), "private-raw") + transactor, err := NewTransactor(client, signer, store, spoolRoot, configuration.timeout) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + return &harness{ + node: node, + client: client, + signer: signer, + store: store, + transactor: transactor, + spoolRoot: spoolRoot, + } +} + +func destination(t *testing.T) common.Address { + t.Helper() + return common.HexToAddress("0x00000000000000000000000000000000000000aa") +} + +// TestExecuteFreezesIntentSignsSpoolsAndSucceeds walks the whole +// intent -> frozen calldata -> signed -> submitted -> receipt chain and checks +// every durable artifact it produces. +func TestExecuteFreezesIntentSignsSpoolsAndSucceeds(t *testing.T) { + subject := newHarness(t, withPendingNonce(4)) + target := destination(t) + request := TxRequest{ + ActionID: "action-fresh", + AttemptID: "attempt-1", + Stage: "root_create", + ChainRole: "a", + To: target, + Value: big.NewInt(1), + Gas: 21_000, + } + result, err := subject.transactor.Execute(context.Background(), request) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.TransactionHash == "" || result.GasUsed != 21_000 || result.BlockNumber == 0 { + t.Fatalf("result = %+v", result) + } + action, err := subject.store.Action(request.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if action.State != state.ActionSucceeded { + t.Fatalf("action state = %q, want succeeded", action.State) + } + if action.Nonce != 4 { + t.Errorf("frozen nonce = %d, want the chain's pending nonce 4", action.Nonce) + } + if action.To != strings.ToLower(target.Hex()) || action.Gas != 21_000 || action.Value != "1" { + t.Errorf("frozen action = %+v", action) + } + if action.CalldataSHA256 != sha256Hex(nil) || action.CalldataBytes != 0 { + t.Errorf("frozen calldata digest = %s/%d", action.CalldataSHA256, action.CalldataBytes) + } + if err := state.VerifyActionIdentity(action); err != nil { + t.Fatalf("VerifyActionIdentity: %v", err) + } + // The raw signed transaction and the receipt are both in the private spool, + // mode 0600, and the receipt digest matches the file. + rawPath := filepath.Join(subject.spoolRoot, strings.TrimPrefix(result.TransactionHash, "0x")+".raw") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatalf("raw spool: %v", err) + } + if hex.EncodeToString(raw) != strings.TrimPrefix(action.RawTransactionHex, "0x") { + t.Fatal("the spooled raw transaction differs from the durable row") + } + if observed := crypto.Keccak256Hash(raw).Hex(); observed != result.TransactionHash { + t.Fatalf("spooled raw hashes to %s, want %s", observed, result.TransactionHash) + } + receiptContents, err := os.ReadFile(result.ReceiptPath) + if err != nil { + t.Fatalf("receipt spool: %v", err) + } + if observed := sha256Hex(receiptContents); observed != result.ReceiptSHA256 { + t.Fatalf("receipt digest = %s, want %s", observed, result.ReceiptSHA256) + } + if action.ReceiptSHA256 != result.ReceiptSHA256 || action.ReceiptPath != result.ReceiptPath { + t.Fatalf("durable receipt evidence = %s/%s", action.ReceiptPath, action.ReceiptSHA256) + } + if !strings.HasSuffix(result.ReceiptPath, ".json") || !strings.HasSuffix(rawPath, ".raw") { + t.Errorf("spool paths = %s, %s", result.ReceiptPath, rawPath) + } + for name, path := range map[string]string{"receipt": result.ReceiptPath, "raw": rawPath} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("%s stat: %v", name, err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("%s mode = %o, want 600", name, mode) + } + } + if mode, err := os.Stat(subject.spoolRoot); err != nil { + t.Fatalf("spool root stat: %v", err) + } else if perm := mode.Mode().Perm(); perm != 0o700 { + t.Errorf("spool root mode = %o, want 700", perm) + } + if result.Detail["receipt"] != result.ReceiptPath || + result.Detail["receipt_sha256"] != result.ReceiptSHA256 || + result.Detail["calldata_bytes"] != 0 || + result.Detail["gas_used"] != uint64(21_000) || + result.Detail["block_number"] != result.BlockNumber { + t.Fatalf("result detail = %#v", result.Detail) + } + // The Python runner ledger is mirrored when the action carries a stage. + stage, err := subject.store.Stage(request.AttemptID, request.Stage) + if err != nil { + t.Fatalf("Stage: %v", err) + } + if stage == nil || stage.State != state.ActionSucceeded || stage.TransactionHash == nil || + *stage.TransactionHash != result.TransactionHash { + t.Fatalf("mirrored stage = %+v", stage) + } + if durable := rawQueryInt(t, subject.store, + "SELECT COUNT(*) FROM durable_signed_transactions WHERE attempt_id = ? AND stage = ?", + request.AttemptID, request.Stage); durable != 1 { + t.Errorf("durable signed rows = %d, want 1", durable) + } + if len(subject.node.recordedBroadcasts()) != 1 { + t.Fatalf("broadcasts = %d, want 1", len(subject.node.recordedBroadcasts())) + } +} + +// TestExecuteReportsSucceededActionWithoutSecondBroadcast is the restart case: +// a previous process signed and submitted, then died. The resumed process finds +// the receipt already on chain and must not broadcast again. +func TestExecuteReportsSucceededActionWithoutSecondBroadcast(t *testing.T) { + subject := newHarness(t, withPendingNonce(9)) + target := destination(t) + data := []byte{0x12, 0x34} + + // Reconstruct what the previous process left behind: a frozen intent, the + // signed bytes and a submitted boundary. + nonce := uint64(9) + gas := uint64(60_000) + transaction := &types.DynamicFeeTx{ + ChainID: big.NewInt(int64(subject.node.chainID)), + Nonce: nonce, + GasTipCap: new(big.Int), + GasFeeCap: big.NewInt(3_000_000_000), + Gas: gas, + To: &target, + Value: big.NewInt(2), + Data: data, + } + signed, err := subject.signer.SignDynamicFee(transaction) + if err != nil { + t.Fatalf("SignDynamicFee: %v", err) + } + raw, err := signed.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + frozen, err := subject.store.Intend(state.Action{ + ActionID: "action-resumed", + AttemptID: "attempt-1", + Stage: "hop_0_h_dispatch", + ChainRole: "a", + ChainID: subject.node.chainID, + Sender: strings.ToLower(subject.signer.Address().Hex()), + To: strings.ToLower(target.Hex()), + CalldataHex: "0x" + hex.EncodeToString(data), + CalldataBytes: len(data), + CalldataSHA256: sha256Hex(data), + Value: "2", + Gas: gas, + Nonce: nonce, + DetailJSON: `{"role": "a"}`, + }) + if err != nil { + t.Fatalf("Intend: %v", err) + } + if err := subject.store.RecordSigned(frozen.ActionID, raw, signed.Hash().Hex()); err != nil { + t.Fatalf("RecordSigned: %v", err) + } + if err := subject.store.Observe(frozen.ActionID, state.ActionSubmitted, nil); err != nil { + t.Fatalf("Observe submitted: %v", err) + } + subject.node.seedReceipt(signed.Hash(), 1, 31, 42_000, nil) + + result, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: frozen.ActionID, + AttemptID: "attempt-1", + Stage: "hop_0_h_dispatch", + ChainRole: "a", + To: target, + Value: big.NewInt(2), + Data: data, + Gas: gas, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if broadcasts := subject.node.recordedBroadcasts(); len(broadcasts) != 0 { + t.Fatalf("broadcasts = %d, want 0 for an action whose receipt already exists", len(broadcasts)) + } + if result.TransactionHash != signed.Hash().Hex() || result.BlockNumber != 31 || result.GasUsed != 42_000 { + t.Fatalf("result = %+v", result) + } + if result.ReceiptSHA256 == "" { + t.Fatal("the resumed action must spool its receipt") + } + contents, err := os.ReadFile(result.ReceiptPath) + if err != nil { + t.Fatalf("receipt spool: %v", err) + } + if observed := sha256Hex(contents); observed != result.ReceiptSHA256 { + t.Fatalf("receipt digest = %s, want %s", observed, result.ReceiptSHA256) + } + action, err := subject.store.Action(frozen.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if action.State != state.ActionSucceeded || action.BlockNumber != 31 { + t.Fatalf("action after resume = %+v", action) + } + // A second replay is answered from the ledger and still does not broadcast. + replayed, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: frozen.ActionID, ChainRole: "a", + }) + if err != nil { + t.Fatalf("Execute replay: %v", err) + } + if replayed.TransactionHash != result.TransactionHash || replayed.ReceiptSHA256 != result.ReceiptSHA256 { + t.Fatalf("replayed result = %+v", replayed) + } + if broadcasts := subject.node.recordedBroadcasts(); len(broadcasts) != 0 { + t.Fatalf("replay broadcast %d transactions, want 0", len(broadcasts)) + } +} + +// TestExecuteAcceptsPriorSubmission proves the three node answers that mean "this +// transaction is already known" are treated as an accepted broadcast, matching +// multihop_runner._transact. +func TestExecuteAcceptsPriorSubmission(t *testing.T) { + for _, message := range []string{"already known", "known transaction: 0xdead", "nonce too low"} { + t.Run(message, func(t *testing.T) { + subject := newHarness(t, withBroadcastError(message)) + result, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: "action-prior", + ChainRole: "a", + To: destination(t), + Value: big.NewInt(1), + Gas: 21_000, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.TransactionHash == "" || result.ReceiptSHA256 == "" { + t.Fatalf("result = %+v", result) + } + if broadcasts := subject.node.recordedBroadcasts(); len(broadcasts) != 1 { + t.Fatalf("broadcast attempts = %d, want 1", len(broadcasts)) + } + action, err := subject.store.Action("action-prior") + if err != nil { + t.Fatalf("Action: %v", err) + } + if action.State != state.ActionSucceeded { + t.Fatalf("action state = %q, want succeeded", action.State) + } + }) + } +} + +// TestExecuteRejectsHardBroadcastFailure keeps the action replayable: a failure +// that is not a prior submission must leave the signed action intact, so the +// caller can retry without re-freezing the intent. +func TestExecuteRejectsHardBroadcastFailure(t *testing.T) { + subject := newHarness( + t, + withBroadcastError("insufficient funds for gas * price + value"), + withoutMining(), + withTransactionTimeout(300*time.Millisecond), + ) + _, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: "action-hard-failure", + ChainRole: "a", + To: destination(t), + Value: big.NewInt(1), + Gas: 21_000, + }) + if err == nil { + t.Fatal("a hard broadcast failure must surface") + } + action, lookupErr := subject.store.Action("action-hard-failure") + if lookupErr != nil { + t.Fatalf("Action: %v", lookupErr) + } + if action.State != state.ActionSigned { + t.Fatalf("action state = %q, want signed so the transaction can be resubmitted", action.State) + } + if err := state.VerifyActionIdentity(action); err != nil { + t.Fatalf("VerifyActionIdentity after a failed broadcast: %v", err) + } +} + +// TestExecuteReturnsTheDeployedAddress covers contract creation: the mined +// receipt's contract address is durable, so re-running a deployment after a +// crash returns the same address instead of losing it. +func TestExecuteReturnsTheDeployedAddress(t *testing.T) { + subject := newHarness(t, withPendingNonce(1)) + created := common.HexToAddress("0x00000000000000000000000000000000000000dd") + initCode := []byte{0x60, 0x00, 0x60, 0x00, 0xf3} + request := TxRequest{ + ActionID: "action-deploy", + ChainRole: "deployment", + DeployCode: initCode, + Gas: 500_000, + } + // Mine the creation receipt the deployment will produce. + subject.node.mineCreation = &created + result, err := subject.transactor.Execute(context.Background(), request) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.ContractAddress != created.Hex() { + t.Fatalf("ContractAddress = %q, want %q", result.ContractAddress, created.Hex()) + } + replayed, err := subject.transactor.Execute(context.Background(), request) + if err != nil { + t.Fatalf("Execute replay: %v", err) + } + if replayed.ContractAddress != created.Hex() { + t.Fatalf("replayed ContractAddress = %q, want %q", replayed.ContractAddress, created.Hex()) + } + if broadcasts := subject.node.recordedBroadcasts(); len(broadcasts) != 1 { + t.Fatalf("broadcasts = %d, want 1", len(broadcasts)) + } + action, err := subject.store.Action(request.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if action.To != "" { + t.Fatalf("a creation must not freeze a destination, got %q", action.To) + } +} + +// TestExecuteRejectsMutatedLedgerRow proves a durable row edited behind the +// store's back cannot be rebroadcast: the frozen fields are re-derived from the +// signed bytes before the second attempt. +func TestExecuteRejectsMutatedLedgerRow(t *testing.T) { + mutations := map[string]string{ + "gas": "UPDATE actions SET gas = 1 WHERE action_id = ?", + "nonce": "UPDATE actions SET nonce = 77 WHERE action_id = ?", + "value": "UPDATE actions SET value = '999' WHERE action_id = ?", + "chain_id": "UPDATE actions SET chain_id = 1 WHERE action_id = ?", + "calldata_hex": "UPDATE actions SET calldata_hex = '0xdead' WHERE action_id = ?", + "transaction": "UPDATE actions SET transaction_hash = '0x' || printf('%.64d', 0) WHERE action_id = ?", + } + for name, statement := range mutations { + t.Run(name, func(t *testing.T) { + subject := newHarness(t, withPendingNonce(3), withoutMining(), withTransactionTimeout(300*time.Millisecond)) + request := TxRequest{ + ActionID: "action-mutated", + ChainRole: "a", + To: destination(t), + Value: big.NewInt(1), + Gas: 21_000, + } + if _, err := subject.transactor.Execute(context.Background(), request); err == nil { + t.Fatal("the first attempt must fail without a receipt") + } + rawExec(t, subject.store, statement, request.ActionID) + _, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: request.ActionID, + ChainRole: "a", + }) + if err == nil { + t.Fatal("a mutated durable row must be rejected") + } + if !errors.Is(err, state.ErrDrift) { + t.Fatalf("error = %v, want ErrDrift", err) + } + }) + } +} + +// TestExecuteRejectsAChangedRequest rejects a caller whose replay disagrees with +// the frozen intent, which is how a wrong calldata or target is caught before it +// is signed. +func TestExecuteRejectsAChangedRequest(t *testing.T) { + subject := newHarness(t, withPendingNonce(2), withoutMining(), withTransactionTimeout(300*time.Millisecond)) + request := TxRequest{ + ActionID: "action-changed", + ChainRole: "a", + To: destination(t), + Data: []byte{0x01, 0x02}, + Gas: 21_000, + } + if _, err := subject.transactor.Execute(context.Background(), request); err == nil { + t.Fatal("the first attempt must fail without a receipt") + } + changed := request + changed.Data = []byte{0x03, 0x04} + if _, err := subject.transactor.Execute(context.Background(), changed); !errors.Is(err, state.ErrDrift) { + t.Fatalf("changed calldata error = %v, want ErrDrift", err) + } + changed = request + changed.To = common.HexToAddress("0x00000000000000000000000000000000000000bb") + if _, err := subject.transactor.Execute(context.Background(), changed); !errors.Is(err, state.ErrDrift) { + t.Fatalf("changed target error = %v, want ErrDrift", err) + } + changed = request + changed.Gas = 30_000 + if _, err := subject.transactor.Execute(context.Background(), changed); !errors.Is(err, state.ErrDrift) { + t.Fatalf("changed gas error = %v, want ErrDrift", err) + } + changed = request + changed.ChainRole = "b" + if _, err := subject.transactor.Execute(context.Background(), changed); !errors.Is(err, state.ErrDrift) { + t.Fatalf("changed chain role error = %v, want ErrDrift", err) + } +} + +// TestReserveNonceDoesNotReuseADurableNonce reproduces +// multihop_runner's startup rule: the reserved nonce starts above every nonce the +// ledger already holds, so a signed transaction that never reached the network +// cannot be overwritten by a different action. +func TestReserveNonceDoesNotReuseADurableNonce(t *testing.T) { + subject := newHarness(t, withPendingNonce(1)) + if _, err := subject.store.Intend(state.Action{ + ActionID: "action-unbroadcast", + ChainRole: "a", + ChainID: subject.node.chainID, + Sender: strings.ToLower(subject.signer.Address().Hex()), + To: strings.ToLower(destination(t).Hex()), + CalldataHex: "0x01", + CalldataBytes: 1, + CalldataSHA256: sha256Hex([]byte{0x01}), + Value: "0", + Gas: 21_000, + Nonce: 12, + }); err != nil { + t.Fatalf("Intend: %v", err) + } + nonce, err := subject.transactor.ReserveNonce(context.Background()) + if err != nil { + t.Fatalf("ReserveNonce: %v", err) + } + if nonce != 13 { + t.Fatalf("reserved nonce = %d, want 13 (above the durable nonce 12)", nonce) + } + next, err := subject.transactor.ReserveNonce(context.Background()) + if err != nil { + t.Fatalf("ReserveNonce: %v", err) + } + if next != 14 { + t.Fatalf("second reserved nonce = %d, want 14", next) + } +} + +// rawQueryInt and rawExec touch the database from outside the store, the way a +// stray manual edit or a truncated spill file would. +func rawQueryInt(t *testing.T, store *state.Store, statement string, args ...any) int64 { + t.Helper() + connection := openRawConnection(t, store) + defer connection.Close() + var value int64 + if err := connection.QueryRow(statement, args...).Scan(&value); err != nil { + t.Fatalf("raw query: %v", err) + } + return value +} + +func rawExec(t *testing.T, store *state.Store, statement string, args ...any) { + t.Helper() + connection := openRawConnection(t, store) + defer connection.Close() + if _, err := connection.Exec(statement, args...); err != nil { + t.Fatalf("raw exec: %v", err) + } +} + +func openRawConnection(t *testing.T, store *state.Store) *sql.DB { + t.Helper() + connection, err := sql.Open("sqlite", store.Path()) + if err != nil { + t.Fatalf("raw open: %v", err) + } + return connection +} + +// TestExecuteSignsAFrozenIntentAfterACrash covers the restart window between the +// durable intent and the signature: the process froze the calldata and the fee +// caps, then died. The resumed process must sign exactly that intent instead of +// choosing new fees. +func TestExecuteSignsAFrozenIntentAfterACrash(t *testing.T) { + subject := newHarness(t, withPendingNonce(6)) + target := destination(t) + subject.node.mineCreation = nil + data := []byte{0xaa, 0xbb, 0xcc} + frozenFee := new(big.Int).SetUint64(7_000_000_000) + detail, err := state.CanonicalJSON(map[string]any{ + "role": "a", + "nonce": 6, + "target": strings.ToLower(target.Hex()), + "calldata_sha256": sha256Hex(data), + "calldata_bytes": len(data), + "chain_id": subject.node.chainID, + "max_fee_per_gas": frozenFee.String(), + "max_priority_fee_per_gas": "0", + }) + if err != nil { + t.Fatalf("CanonicalJSON: %v", err) + } + if _, err := subject.store.Intend(state.Action{ + ActionID: "action-frozen-intent", + ChainRole: "a", + ChainID: subject.node.chainID, + Sender: strings.ToLower(subject.signer.Address().Hex()), + To: strings.ToLower(target.Hex()), + CalldataHex: "0x" + hex.EncodeToString(data), + CalldataBytes: len(data), + CalldataSHA256: sha256Hex(data), + Value: "0", + Gas: 45_000, + Nonce: 6, + DetailJSON: detail, + }); err != nil { + t.Fatalf("Intend: %v", err) + } + result, err := subject.transactor.Execute(context.Background(), TxRequest{ + ActionID: "action-frozen-intent", + ChainRole: "a", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + broadcasts := subject.node.recordedBroadcasts() + if len(broadcasts) != 1 { + t.Fatalf("broadcasts = %d, want 1", len(broadcasts)) + } + var signed types.Transaction + if err := signed.UnmarshalBinary(broadcasts[0]); err != nil { + t.Fatalf("the broadcast bytes are not a transaction: %v", err) + } + if signed.GasFeeCap().Cmp(frozenFee) != 0 { + t.Fatalf("signed fee cap = %s, want the frozen %s", signed.GasFeeCap(), frozenFee) + } + if signed.GasTipCap().Sign() != 0 { + t.Fatalf("signed priority fee = %s, want 0", signed.GasTipCap()) + } + if signed.Nonce() != 6 || signed.Gas() != 45_000 || signed.Value().Sign() != 0 { + t.Fatalf("signed transaction = nonce %d, gas %d, value %s", signed.Nonce(), signed.Gas(), signed.Value()) + } + if signed.To() == nil || *signed.To() != target { + t.Fatalf("signed target = %v, want %s", signed.To(), target) + } + if string(signed.Data()) != string(data) { + t.Fatalf("signed calldata = 0x%x, want 0x%x", signed.Data(), data) + } + if result.TransactionHash != signed.Hash().Hex() { + t.Fatalf("result hash = %s, want %s", result.TransactionHash, signed.Hash().Hex()) + } +} + +// TestReserveNonceIsScopedToOneAccount is the regression guard for the reported +// route blocker: the runner and the embedded protocol agents share a chain, each +// with its own nonce space. A durable action signed by another account must not +// raise the runner's floor, while the runner's own durable action still must. +func TestReserveNonceIsScopedToOneAccount(t *testing.T) { + subject := newHarness(t, withPendingNonce(0), withoutMining(), + withTransactionTimeout(300*time.Millisecond)) + otherKey := loadVectors(t).Constants.FixtureKeys["validator"] + if otherKey == "" || otherKey == testPrivateKey { + t.Fatal("the vectors must carry a second distinct fixture key") + } + otherSigner, err := NewSigner(otherKey, big.NewInt(int64(subject.node.chainID))) + if err != nil { + t.Fatalf("NewSigner(other): %v", err) + } + // The other account consumed nonce 0 on this chain. + target := destination(t) + transaction := &types.DynamicFeeTx{ + ChainID: big.NewInt(int64(subject.node.chainID)), + Nonce: 0, + GasTipCap: new(big.Int), + GasFeeCap: big.NewInt(2_000_000_000), + Gas: 21_000, + To: &target, + Value: big.NewInt(0), + } + signed, err := otherSigner.SignDynamicFee(transaction) + if err != nil { + t.Fatalf("SignDynamicFee: %v", err) + } + raw, err := signed.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + foreign, err := subject.store.Intend(state.Action{ + ActionID: "action-foreign-account", + ChainRole: "b", + ChainID: subject.node.chainID, + Sender: strings.ToLower(otherSigner.Address().Hex()), + To: strings.ToLower(target.Hex()), + CalldataHex: "0x", + CalldataBytes: 0, + CalldataSHA256: sha256Hex(nil), + Value: "0", + Gas: 21_000, + Nonce: 0, + }) + if err != nil { + t.Fatalf("Intend: %v", err) + } + if err := subject.store.RecordSigned(foreign.ActionID, raw, signed.Hash().Hex()); err != nil { + t.Fatalf("RecordSigned: %v", err) + } + // The runner account has sent nothing, so its first transaction must be + // frozen with nonce 0 even though another account already holds nonce 0 on + // the same chain. The transaction is not mined by the fake node, so Execute + // stops at the receipt wait; the frozen intent is what this test reads. + own := TxRequest{ + ActionID: "action-own-account", + ChainRole: "b", + To: target, + Gas: 21_000, + } + if _, err := subject.transactor.Execute(context.Background(), own); err == nil { + t.Fatal("Execute must fail while no receipt appears") + } + frozen, err := subject.store.Action(own.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if frozen.Nonce != 0 { + t.Fatalf("frozen nonce = %d, want 0: another account's actions must not raise the floor", frozen.Nonce) + } + if frozen.Sender != strings.ToLower(subject.signer.Address().Hex()) { + t.Fatalf("frozen sender = %q", frozen.Sender) + } + // The runner's own durable nonce 0 does raise it, so a restarted transactor + // cannot hand nonce 0 out a second time. + restarted, err := NewTransactor(subject.client, subject.signer, subject.store, subject.spoolRoot, time.Second) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + nonce, err := restarted.ReserveNonce(context.Background()) + if err != nil { + t.Fatalf("ReserveNonce: %v", err) + } + if nonce != 1 { + t.Fatalf("reserved nonce = %d, want 1 (above this account's durable nonce 0)", nonce) + } + // A second transactor signing with another key is unaffected by the above. + nonce, err = otherTransactor(t, subject, otherKey).ReserveNonce(context.Background()) + if err != nil { + t.Fatalf("ReserveNonce(other): %v", err) + } + if nonce != 1 { + t.Fatalf("reserved nonce for the other account = %d, want 1", nonce) + } +} + +func otherTransactor(t *testing.T, subject *harness, privateKey string) *Transactor { + t.Helper() + signer, err := NewSigner(privateKey, big.NewInt(int64(subject.node.chainID))) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + transactor, err := NewTransactor(subject.client, signer, subject.store, subject.spoolRoot, time.Second) + if err != nil { + t.Fatalf("NewTransactor: %v", err) + } + return transactor +} + +func TestNewTransactorRejectsAMismatchedChain(t *testing.T) { + node := newFakeNode(t) + client := dialFake(t, node) + signer, err := NewSigner(testPrivateKey, big.NewInt(99)) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + store, err := state.Open(filepath.Join(t.TempDir(), "runner.sqlite")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + defer store.Close() + if _, err := NewTransactor(client, signer, store, filepath.Join(t.TempDir(), "raw"), 0); err == nil { + t.Fatal("a signer bound to another chain must be rejected") + } +} + +func TestExecuteFreezesCallerIntentAndRejectsDrift(t *testing.T) { + h := newHarness(t) + request := TxRequest{ActionID: "caller-intent", ChainRole: "a", To: destination(t), Gas: 100000, IntentDetail: map[string]any{"record_nonce": uint64(7)}} + first, err := h.transactor.Execute(context.Background(), request) + if err != nil { + t.Fatal(err) + } + action, err := h.store.Action(request.ActionID) + if err != nil { + t.Fatal(err) + } + detail, err := action.Detail() + if err != nil { + t.Fatal(err) + } + frozen, err := state.CanonicalJSON(detail["intent_detail"]) + expected, _ := state.CanonicalJSON(request.IntentDetail) + if err != nil || frozen != expected { + t.Fatalf("intent not persisted: %s %v", frozen, err) + } + again, err := h.transactor.Execute(context.Background(), request) + if err != nil || again.TransactionHash != first.TransactionHash { + t.Fatalf("replay: %+v %v", again, err) + } + request.IntentDetail["record_nonce"] = uint64(8) + if _, err := h.transactor.Execute(context.Background(), request); !errors.Is(err, state.ErrDrift) { + t.Fatalf("changed intent was not rejected: %v", err) + } +} diff --git a/go-runtime/internal/hyperlane/checkpoint.go b/go-runtime/internal/hyperlane/checkpoint.go new file mode 100644 index 0000000..a1f5233 --- /dev/null +++ b/go-runtime/internal/hyperlane/checkpoint.go @@ -0,0 +1,139 @@ +package hyperlane + +import ( + "encoding/binary" + "errors" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// domainSeparator is the literal every Hyperlane signing domain is bound to. +// +// solidity/contracts/libs/CheckpointLib.sol:90 — `abi.encodePacked(_origin, +// _merkleTreeHook, "HYPERLANE")`. A string literal in `abi.encodePacked` is +// appended as its 8 raw ASCII bytes, with no length word and no padding. +const domainSeparator = "HYPERLANE" + +// Checkpoint is the origin MerkleTreeHook checkpoint validators sign: which tree +// (Domain), which root (Root) and which leaf index (Index). +// +// The signing struct is larger +// (`Checkpoint{origin, merkleTree, root, index, messageId}`, +// solidity/contracts/libs/CheckpointLib.sol:7-13); the merkle tree address and +// the message id are supplied separately here because the tree address is ISM +// metadata field 0 and the message id is derived from the message itself. +type Checkpoint struct { + Domain uint32 + Root [32]byte + Index uint32 +} + +// DomainHash returns the signing domain of one origin tree. +// +// solidity/contracts/libs/CheckpointLib.sol:80-91: +// +// keccak256(abi.encodePacked(uint32 origin, bytes32 merkleTreeHook, "HYPERLANE")) +// +// so the digest preimage is exactly 45 bytes: 4 big-endian bytes of origin, the +// 32-byte left-padded hook address, then the 9 ASCII bytes "HYPERLANE". The +// comment at CheckpointLib.sol:84-88 records why the tree address is inside the +// domain hash: without it a signature for tree A would be indistinguishable from +// one for tree B, and a slashing protocol could not attribute it. +// +// The Rust agent computes the same bytes with the arguments in the opposite +// order (`domain_hash(merkle_tree_hook_address, domain)`, +// rust/main/hyperlane-core/src/utils.rs:47-57); the hashed field order is +// unchanged: domain first, then address. +func DomainHash(domain uint32, merkleTreeHook common.Address) [32]byte { + hook := AddressToBytes32(merkleTreeHook) + preimage := make([]byte, 0, 4+32+len(domainSeparator)) + preimage = binary.BigEndian.AppendUint32(preimage, domain) + preimage = append(preimage, hook[:]...) + preimage = append(preimage, domainSeparator...) + return xir.Keccak256(preimage) +} + +// Hash returns the 32-byte hash a validator signs with `eth_sign` for one +// message: the checkpoint's own hash, before EIP-191 prefixing. +// +// solidity/contracts/libs/CheckpointLib.sol:28-45 hashes a 100-byte preimage: +// +// keccak256( +// abi.encodePacked(domainHash, root, uint32 index, messageId) +// ) +// +// with `messageId` = `keccak256(message)` (see MessageID). Feed this value to a +// personal-message signer (internal/evm's `Signer.SignPersonalDigest`), which +// applies the prefixing itself; use Digest when a digest has to be verified +// without a signer. +func (c Checkpoint) Hash(merkleTreeHook common.Address, messageID [32]byte) [32]byte { + domainHash := DomainHash(c.Domain, merkleTreeHook) + preimage := make([]byte, 0, 32+32+4+32) + preimage = append(preimage, domainHash[:]...) + preimage = append(preimage, c.Root[:]...) + preimage = binary.BigEndian.AppendUint32(preimage, c.Index) + preimage = append(preimage, messageID[:]...) + return xir.Keccak256(preimage) +} + +// Digest returns the value the deployed ISM recovers a validator from, i.e. the +// checkpoint hash with the EIP-191 personal-sign prefix already applied. +// +// Which bytes are hashed and which are prefixed, exactly: +// +// - hashed (100 bytes, CheckpointLib.sol:39-44): domainHash [32] || root [32] +// || uint32 index [4, big-endian] || messageId [32]; +// - prefixed (CheckpointLib.sol:35-38): that keccak256 output is wrapped by +// `ECDSA.toEthSignedMessageHash`, i.e. keccak256 of +// "\x19Ethereum Signed Message:\n32" (28 bytes) || the 32-byte hash +// (openzeppelin-contracts v4.9.3 ECDSA.sol:165-174); +// - recovered (solidity/contracts/isms/multisig/AbstractMultisigIsm.sol:110): +// `ECDSA.recover(_digest, signatureAt(_metadata, i))` — OZ `recover` applies +// no further prefix, so this Digest value is what `ecrecover` sees. +// +// Equivalently (AbstractMessageIdMultisigIsm.sol:29-41), the ISM builds it from +// `_message.origin()`, metadata fields [0:32] and [32:64] as the tree and root, +// metadata field [64:68] as the index, and `_message.id()`. +// +// A validator therefore signs Hash (via personal signing) and the chain verifies +// Digest: one signature, two representations of the same statement. +func (c Checkpoint) Digest(merkleTreeHook common.Address, messageID [32]byte) [32]byte { + return personalDigest(c.Hash(merkleTreeHook, messageID)) +} + +// CheckpointDigest returns the 32-byte signing hash of one +// (origin domain, origin tree hook, root, index, messageID) tuple. +// +// It is exactly `Checkpoint.Hash` for the same tuple: the 100-byte preimage +// above, hashed and *not* yet EIP-191 prefixed. Pass it to a personal-message +// signer — `SignPersonalDigest(CheckpointDigest(...))` yields the signature that +// goes into the metadata and that the deployed ISM recovers from +// `Checkpoint.Digest`, which is the prefixed form of this value. The distinction +// matters: signing this hash with a raw (non-personal) signer would recover a +// different address on chain and `process` would revert with "ISM verification +// failed" (solidity/contracts/Mailbox.sol:235-238). +// +// originMerkleTreeHook is the origin MerkleTreeHook, i.e. the emitter of the +// `InsertedIntoTree` log and the contract `MerkleTreeHook.latestCheckpoint()` is +// read from, not the origin Mailbox: the tree address is the second component of +// the domain hash (CheckpointLib.sol:80-91) and metadata field [0:32] +// (MessageIdMultisigIsmMetadata.sol:5-9), and the ISM never sees a mailbox +// address. Substituting the Mailbox silently produces a digest no validator ever +// signed. +func CheckpointDigest( + originDomain uint32, + originMerkleTreeHook common.Address, + root [32]byte, + index uint32, + messageID [32]byte, +) ([32]byte, error) { + if originMerkleTreeHook == (common.Address{}) { + return [32]byte{}, errors.New( + "hyperlane: origin merkle tree hook is the zero address, not a MerkleTreeHook", + ) + } + checkpoint := Checkpoint{Domain: originDomain, Root: root, Index: index} + return checkpoint.Hash(originMerkleTreeHook, messageID), nil +} diff --git a/go-runtime/internal/hyperlane/evidence.go b/go-runtime/internal/hyperlane/evidence.go new file mode 100644 index 0000000..7d967c6 --- /dev/null +++ b/go-runtime/internal/hyperlane/evidence.go @@ -0,0 +1,35 @@ +package hyperlane + +import ( + "encoding/binary" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// EvidenceHash returns the evidence hash the XIR runtime records for a Hyperlane +// hop and the analysis tooling recomputes. +// +// src/xir_lab/native/multihop_runner.py:1190-1200 (`_dispatch_hop`, protocol "H"): +// +// evidence = keccak(encode(["uint32", "bytes32", "bytes"], [domain, sender, body])) +// +// where `domain` is the source role's Hyperlane domain, `sender` is the origin +// adapter address as bytes32 (12 zero bytes then the 20 address bytes), and `body` +// is the adapter's outbound body — `abi.encode(uint8 kind, bytes inner)` with +// kind 3 for an XIR bundle. The same triple is asserted against the parity +// vectors in go-runtime/testdata/vectors.json. +// +// The encoding is standard ABI encoding, not `abi.encodePacked`: three 32-byte +// head words (domain, sender, offset 96) followed by the length of body and the +// body padded to a 32-byte boundary. The domain occupies the low 4 bytes of its +// word and the high 28 bytes are zero. +func EvidenceHash(domain uint32, sender [32]byte, body []byte) [32]byte { + bodyWords := (len(body) + 31) / 32 + encoded := make([]byte, (4+bodyWords)*32) + binary.BigEndian.PutUint32(encoded[28:32], domain) + copy(encoded[32:64], sender[:]) + binary.BigEndian.PutUint64(encoded[88:96], 96) + binary.BigEndian.PutUint64(encoded[120:128], uint64(len(body))) + copy(encoded[128:], body) + return xir.Keccak256(encoded) +} diff --git a/go-runtime/internal/hyperlane/hyperlane.go b/go-runtime/internal/hyperlane/hyperlane.go new file mode 100644 index 0000000..b218e03 --- /dev/null +++ b/go-runtime/internal/hyperlane/hyperlane.go @@ -0,0 +1,117 @@ +// Package hyperlane implements the Hyperlane v3 wire and verification layer the +// XIR runtime needs to deliver a hop that was dispatched through +// contracts/src/HyperlaneAdapter.sol. +// +// Every layout, digest and constant is byte exact with the pinned upstream +// commit hyperlane-xyz/hyperlane-monorepo@5857ead81a8783d168d48d370be72de88d5fb230 +// (cited below as `solidity/...:`), which is the contract stack the lab +// deploys on the origin and destination chains. +// +// The relayer path this package reproduces is short: +// +// 1. the origin Mailbox packs a message (message.go) and a MerkleTreeHook +// inserts its id as a leaf of the origin tree (OriginMerkleTreeHook); +// 2. validators sign the checkpoint of that leaf (checkpoint.go, validator.go); +// 3. the relayer pairs the signed checkpoint with the validator signatures into +// ISM metadata (metadata.go) and submits one call, +// Mailbox.process(metadata, message), on the destination chain (relayer.go); +// 4. Mailbox computes the digest again, recovers the validators from the +// metadata signatures and hands the body to the recipient adapter. +// +// This package performs no I/O: it derives payloads, digests and calldata, and +// decodes dispatch receipts. Broadcasting the planned call is the caller's job. +// +// Every event topic in this package is keccak256 of its canonical Solidity +// signature (DispatchTopic, DispatchIDTopic, InsertedIntoTreeTopic, +// ProcessTopic, ProcessIDTopic), never a hand-copied hex literal, and the tests +// pin each derived value against a keccak computed outside Go. +package hyperlane + +import ( + "errors" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// Version is the Hyperlane message version this runtime speaks. +// +// solidity/contracts/upgrade/Versioned.sol:9 — `uint8 public constant VERSION = 3`. +// Mailbox inherits it and uses it both when building a message +// (solidity/contracts/Mailbox.sol:441) and when validating one +// (solidity/contracts/Mailbox.sol:209). +const Version uint8 = 3 + +// personalDigestPrefix is the EIP-191 personal-sign prefix for a 32-byte payload, +// i.e. the bytes OpenZeppelin's `ECDSA.toEthSignedMessageHash` prepends +// (openzeppelin-contracts v4.9.3 utils/cryptography/ECDSA.sol:165-174, which +// keccak256s a 60-byte preimage built from this 28-byte prefix and the hash). +var personalDigestPrefix = []byte("\x19Ethereum Signed Message:\n32") + +// personalDigest applies the EIP-191 personal-sign prefixing to one 32-byte hash. +// It is the exact transformation `eth_account.messages.encode_defunct` performs, +// which is why a validator signed digest and an ISM recovered digest agree. +func personalDigest(hash [32]byte) [32]byte { + return xir.Keccak256(personalDigestPrefix, hash[:]) +} + +// AddressToBytes32 left-pads an address with 12 zero bytes. +// +// solidity/contracts/libs/TypeCasts.sol:6-8 — `addressToBytes32` shifts the +// address left by 96 bits. +func AddressToBytes32(address common.Address) [32]byte { + var value [32]byte + copy(value[12:], address[:]) + return value +} + +// Bytes32ToAddress returns the address encoded in the low 20 bytes of value and +// rejects a value whose upper 96 bits are non-zero. +// +// solidity/contracts/libs/TypeCasts.sol:11-17 — `bytes32ToAddress` reverts with +// "TypeCasts: bytes32ToAddress overflow" instead of truncating. +func Bytes32ToAddress(value [32]byte) (common.Address, error) { + for _, digit := range value[:12] { + if digit != 0 { + return common.Address{}, errBytes32Overflow + } + } + return common.BytesToAddress(value[12:]), nil +} + +// Event topics, derived from the canonical signatures the contracts declare. +// Each value is keccak256 of the signature text, so it can never drift from a +// re-derived value; the tests pin every one of them against an independent +// keccak computed outside Go. +var ( + // DispatchTopic is `Dispatch(address,uint32,bytes32,bytes)` + // (solidity/contracts/interfaces/IMailbox.sol:16-21). The log carries the + // raw message in its data and sender/destination/recipient in its topics. + DispatchTopic = topic("Dispatch(address,uint32,bytes32,bytes)") + + // DispatchIDTopic is `DispatchId(bytes32)` (IMailbox.sol:27). + DispatchIDTopic = topic("DispatchId(bytes32)") + + // InsertedIntoTreeTopic is `InsertedIntoTree(bytes32,uint32)`, emitted by the + // origin MerkleTreeHook with the pre-insertion leaf index + // (solidity/contracts/hooks/MerkleTreeHook.sol:32, emitted at :76). The log + // comes from the hook address, not from the Mailbox, so it must be filtered + // by emitter. + InsertedIntoTreeTopic = topic("InsertedIntoTree(bytes32,uint32)") + + // ProcessTopic is `Process(uint32,bytes32,address)` (IMailbox.sol:41-45), + // emitted by the destination Mailbox once the message is delivered. + ProcessTopic = topic("Process(uint32,bytes32,address)") + + // ProcessIDTopic is `ProcessId(bytes32)` (IMailbox.sol:33). + ProcessIDTopic = topic("ProcessId(bytes32)") +) + +func topic(signature string) common.Hash { + return common.Hash(xir.Keccak256([]byte(signature))) +} + +// errBytes32Overflow mirrors the revert of TypeCasts.bytes32ToAddress: a bytes32 +// that does not fit an address is not silently truncated. +var errBytes32Overflow = errors.New("hyperlane: bytes32 value does not fit an address") diff --git a/go-runtime/internal/hyperlane/hyperlane_test.go b/go-runtime/internal/hyperlane/hyperlane_test.go new file mode 100644 index 0000000..f510cc3 --- /dev/null +++ b/go-runtime/internal/hyperlane/hyperlane_test.go @@ -0,0 +1,805 @@ +package hyperlane + +import ( + "bytes" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +// The layout vectors below are pinned against values computed outside this +// package with `cast keccak` / `cast sig` / `cast wallet address` +// (/home/ubuntu/.foundry/bin/cast, foundry), so a change in this package's +// digest composition cannot pass by agreeing with itself. +const ( + // vectorMessage is FormatMessage(3, 1, 31337, sender, 31338, recipient, "XIR") + // with sender = 0x00..00dd..dd and recipient = 0x00..00ee..ee: + // version[0] || nonce[1:5] || origin[5:9] || sender[9:41] || destination[41:45] + // || recipient[45:77] || body[77:] (Message.sol:33-52). + vectorMessage = "0x030000000100007a69000000000000000000000000dddddddddddddddddddddddddddddddddddddddd00007a6a000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee584952" + + // vectorMessageID is cast keccak of vectorMessage (Message.sol:59-61). + vectorMessageID = "0x8b9dcbed27d82d99f2b1a7a9a868a9d9a36545795a6be230356927b037411b78" + + // vectorOriginDomain, vectorHook, vectorRoot and vectorIndex are the checkpoint + // inputs the digest vectors were computed from. + vectorOriginDomain = uint32(31337) + vectorHook = "0x1111111111111111111111111111111111111111" + vectorRoot = "0x2222222222222222222222222222222222222222222222222222222222222222" + vectorIndex = uint32(5) + + // vectorDomainHash is cast keccak of the 44-byte preimage + // abi.encodePacked(uint32 31337, bytes32 hook, "HYPERLANE") + // (CheckpointLib.sol:80-91). + vectorDomainHash = "0xbf6e61acfa618e3081642d2345843ab79d4caac18f03d6c0329f9f804a3ffa1e" + + // vectorSigningHash is cast keccak of the 100-byte preimage + // domainHash || root || uint32 5 || messageID (CheckpointLib.sol:39-44): the + // value a validator signs with eth_sign. + vectorSigningHash = "0x2a43dd208a4311a928fa0efbd2eae40033c8ebab43c4af40938f72678825e08d" + + // vectorISMDigest is cast keccak of the EIP-191 preimage + // "\x19Ethereum Signed Message:\n32" || vectorSigningHash: the 32-byte value + // AbstractMultisigIsm.verify passes to ECDSA.recover (:110). + vectorISMDigest = "0x577403f6f78eeb6ad829f39b6ab5af233a79d8df774f2272b981607f5ddbb202" + + // vectorProcessSelector is cast sig "process(bytes,bytes)" (Mailbox.sol:202-207). + vectorProcessSelector = "0x7c39d130" + + // vectorValidatorAddress is cast wallet address of the fixture validator key in + // go-runtime/testdata/vectors.json (constants.fixture_keys.validator). + vectorValidatorAddress = "0xC9aFf155a8DF8FEA85741236eFe4BF3062802a3D" +) + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(value[2:]) + if err != nil { + t.Fatalf("decode %s: %v", value, err) + } + return decoded +} + +func mustHash(t *testing.T, value string) common.Hash { + t.Helper() + decoded := mustHex(t, value) + if len(decoded) != 32 { + t.Fatalf("hash %s has length %d", value, len(decoded)) + } + var hash common.Hash + copy(hash[:], decoded) + return hash +} + +func mustBytes32(t *testing.T, value string) [32]byte { + t.Helper() + return [32]byte(mustHash(t, value)) +} + +func mustAddress(t *testing.T, value string) common.Address { + t.Helper() + return common.HexToAddress(value) +} + +// vectorMessageBytes rebuilds the pinned message from its fields, so the layout +// test asserts both the packing order and the accessors against the same bytes. +func vectorMessageBytes(t *testing.T) []byte { + t.Helper() + var sender, recipient [32]byte + copy(sender[12:], bytes.Repeat([]byte{0xdd}, 20)) + copy(recipient[12:], bytes.Repeat([]byte{0xee}, 20)) + return FormatMessage(Version, 1, vectorOriginDomain, sender, 31338, recipient, []byte("XIR")) +} + +func TestFormatMessageLayoutAndID(t *testing.T) { + message := vectorMessageBytes(t) + if "0x"+hex.EncodeToString(message) != vectorMessage { + t.Fatalf("FormatMessage = 0x%s, want %s", hex.EncodeToString(message), vectorMessage) + } + if len(message) != HeaderLength+3 { + t.Fatalf("message length %d, want %d", len(message), HeaderLength+3) + } + decoded := Message(message) + var sender, recipient [32]byte + copy(sender[12:], bytes.Repeat([]byte{0xdd}, 20)) + copy(recipient[12:], bytes.Repeat([]byte{0xee}, 20)) + for _, field := range []struct { + name string + got any + want any + }{ + {"version", decoded.Version(), Version}, + {"nonce", decoded.Nonce(), uint32(1)}, + {"origin", decoded.Origin(), vectorOriginDomain}, + {"sender", decoded.Sender(), sender}, + {"destination", decoded.Destination(), uint32(31338)}, + {"recipient", decoded.Recipient(), recipient}, + {"body", hex.EncodeToString(decoded.Body()), hex.EncodeToString([]byte("XIR"))}, + } { + if field.got != field.want { + t.Errorf("message %s = %v, want %v", field.name, field.got, field.want) + } + } + if err := decoded.Validate(); err != nil { + t.Errorf("Validate: %v", err) + } + if _, err := decoded.SenderAddress(); err != nil { + t.Errorf("SenderAddress: %v", err) + } + if _, err := decoded.RecipientAddress(); err != nil { + t.Errorf("RecipientAddress: %v", err) + } + if got := MessageID(message); got != mustHash(t, vectorMessageID) { + t.Errorf("MessageID = %s, want %s", got, vectorMessageID) + } + if got := decoded.ID(); got != MessageID(message) { + t.Errorf("Message.ID = %s, want %s", got, MessageID(message)) + } + // A second, independent keccak implementation over the same bytes. + if got := crypto.Keccak256Hash(message); got != mustHash(t, vectorMessageID) { + t.Errorf("crypto.Keccak256Hash = %s, want %s", got, vectorMessageID) + } +} + +func TestMessageAccessorsOnShortInput(t *testing.T) { + truncated := vectorMessageBytes(t)[:HeaderLength-1] + decoded := Message(truncated) + if err := decoded.Validate(); err == nil { + t.Fatal("Validate accepted a message shorter than the header") + } + if decoded.Body() != nil { + t.Errorf("Body = %x, want nil", decoded.Body()) + } + if decoded.Recipient() != ([32]byte{}) { + t.Errorf("Recipient = %x, want zero", decoded.Recipient()) + } +} + +func TestTypeCasts(t *testing.T) { + address := mustAddress(t, vectorHook) + encoded := AddressToBytes32(address) + if !bytes.Equal(encoded[:12], make([]byte, 12)) || !bytes.Equal(encoded[12:], address.Bytes()) { + t.Fatalf("AddressToBytes32 = %x", encoded) + } + decoded, err := Bytes32ToAddress(encoded) + if err != nil { + t.Fatalf("Bytes32ToAddress: %v", err) + } + if decoded != address { + t.Fatalf("Bytes32ToAddress = %s, want %s", decoded, address) + } + overflowing := encoded + overflowing[0] = 1 + if _, err := Bytes32ToAddress(overflowing); err == nil { + t.Fatal("Bytes32ToAddress accepted a value with non-zero upper bits") + } +} + +func TestCheckpointDigestComposition(t *testing.T) { + hook := mustAddress(t, vectorHook) + root := mustBytes32(t, vectorRoot) + messageID := mustHash(t, vectorMessageID) + + if got := DomainHash(vectorOriginDomain, hook); got != mustHash(t, vectorDomainHash) { + t.Errorf("DomainHash = %s, want %s", got, vectorDomainHash) + } + + checkpoint := Checkpoint{Domain: vectorOriginDomain, Root: root, Index: vectorIndex} + signingHash := checkpoint.Hash(hook, messageID) + if signingHash != mustHash(t, vectorSigningHash) { + t.Errorf("Checkpoint.Hash = %s, want %s", signingHash, vectorSigningHash) + } + if got := checkpoint.Digest(hook, messageID); got != mustHash(t, vectorISMDigest) { + t.Errorf("Checkpoint.Digest = %s, want %s", got, vectorISMDigest) + } + if signingHash == checkpoint.Digest(hook, messageID) { + t.Fatal("Checkpoint.Hash and Checkpoint.Digest must differ: the digest is prefixed") + } + + // Locally assembled expectation: the same preimages CheckpointLib hashes, + // built here from raw bytes with no help from this package's helpers. + encodedHook := AddressToBytes32(hook) + domainPreimage := append([]byte{0x00, 0x00, 0x7a, 0x69}, encodedHook[:]...) + domainPreimage = append(domainPreimage, []byte("HYPERLANE")...) + if len(domainPreimage) != 45 { + t.Fatalf("domain preimage length %d, want 45", len(domainPreimage)) + } + expectedDomain := crypto.Keccak256Hash(domainPreimage) + if expectedDomain != mustHash(t, vectorDomainHash) { + t.Errorf("locally computed domain hash = %s, want %s", expectedDomain, vectorDomainHash) + } + indexPreimage := make([]byte, 0, 100) + indexPreimage = append(indexPreimage, expectedDomain[:]...) + indexPreimage = append(indexPreimage, root[:]...) + indexPreimage = append(indexPreimage, 0x00, 0x00, 0x00, 0x05) + indexPreimage = append(indexPreimage, messageID[:]...) + if len(indexPreimage) != 100 { + t.Fatalf("preimage length %d, want 100", len(indexPreimage)) + } + expectedSigningHash := crypto.Keccak256Hash(indexPreimage) + if expectedSigningHash != signingHash { + t.Errorf("locally computed signing hash = %s, want %s", expectedSigningHash, signingHash) + } + expectedDigest := crypto.Keccak256Hash( + append([]byte("\x19Ethereum Signed Message:\n32"), expectedSigningHash[:]...), + ) + if expectedDigest != checkpoint.Digest(hook, messageID) { + t.Errorf("locally computed digest = %s, want %s", expectedDigest, checkpoint.Digest(hook, messageID)) + } + + // The free function is the same derivation, and refuses to sign for a tree + // address that cannot exist. + free, err := CheckpointDigest(vectorOriginDomain, hook, root, vectorIndex, messageID) + if err != nil { + t.Fatalf("CheckpointDigest: %v", err) + } + if free != signingHash { + t.Errorf("CheckpointDigest = %s, want %s", free, signingHash) + } + if _, err := CheckpointDigest(vectorOriginDomain, common.Address{}, root, vectorIndex, messageID); err == nil { + t.Error("CheckpointDigest accepted the zero tree hook address") + } +} + +func TestMetadataLayout(t *testing.T) { + hook := mustAddress(t, vectorHook) + root := mustBytes32(t, vectorRoot) + signatureOne := append(bytes.Repeat([]byte{0x01}, 32), bytes.Repeat([]byte{0x02}, 32)...) + signatureOne = append(signatureOne, 27) + signatureTwo := append(bytes.Repeat([]byte{0x03}, 32), bytes.Repeat([]byte{0x04}, 32)...) + signatureTwo = append(signatureTwo, 28) + + if got, want := MetadataLength(1), 133; got != want { + t.Errorf("MetadataLength(1) = %d, want %d", got, want) + } + if got, want := MetadataLength(2), 198; got != want { + t.Errorf("MetadataLength(2) = %d, want %d", got, want) + } + + for _, test := range []struct { + name string + signatures []byte + threshold int + }{ + {"threshold-1", signatureOne, 1}, + {"threshold-2", append(append([]byte{}, signatureOne...), signatureTwo...), 2}, + } { + t.Run(test.name, func(t *testing.T) { + metadata, err := Metadata(hook, root, vectorIndex, test.signatures) + if err != nil { + t.Fatalf("Metadata: %v", err) + } + if len(metadata) != MetadataLength(test.threshold) { + t.Fatalf("metadata length %d, want %d", len(metadata), MetadataLength(test.threshold)) + } + if !bytes.Equal(metadata[:12], make([]byte, 12)) { + t.Errorf("metadata[0:12] = %x, want 12 zero bytes", metadata[:12]) + } + if got := metadata[12:32]; !bytes.Equal(got, hook.Bytes()) { + t.Errorf("metadata[12:32] = %x, want %x", got, hook.Bytes()) + } + if got := metadata[32:64]; !bytes.Equal(got, root[:]) { + t.Errorf("metadata[32:64] = %x, want %x", got, root[:]) + } + if got := metadata[64:68]; !bytes.Equal(got, []byte{0x00, 0x00, 0x00, 0x05}) { + t.Errorf("metadata[64:68] = %x, want 00000005", got) + } + if got := metadata[68:]; !bytes.Equal(got, test.signatures) { + t.Errorf("metadata[68:] = %x, want %x", got, test.signatures) + } + for index := 0; index < test.threshold; index++ { + want := test.signatures[index*SignatureLength : (index+1)*SignatureLength] + got, err := MetadataSignatureAt(metadata, index) + if err != nil { + t.Fatalf("MetadataSignatureAt(%d): %v", index, err) + } + if !bytes.Equal(got, want) { + t.Errorf("signature %d = %x, want %x", index, got, want) + } + } + decodedHook, err := MetadataOriginMerkleTreeHook(metadata) + if err != nil { + t.Fatalf("MetadataOriginMerkleTreeHook: %v", err) + } + address, err := Bytes32ToAddress(decodedHook) + if err != nil { + t.Fatalf("Bytes32ToAddress: %v", err) + } + if address != hook { + t.Errorf("metadata hook = %s, want %s", address, hook) + } + decodedRoot, err := MetadataRoot(metadata) + if err != nil { + t.Fatalf("MetadataRoot: %v", err) + } + if decodedRoot != root { + t.Errorf("metadata root = %x, want %x", decodedRoot, root) + } + decodedIndex, err := MetadataIndex(metadata) + if err != nil { + t.Fatalf("MetadataIndex: %v", err) + } + if decodedIndex != vectorIndex { + t.Errorf("metadata index = %d, want %d", decodedIndex, vectorIndex) + } + count, err := SignatureCount(test.signatures) + if err != nil { + t.Fatalf("SignatureCount: %v", err) + } + if count != test.threshold { + t.Errorf("SignatureCount = %d, want %d", count, test.threshold) + } + // Checkpoint.Metadata takes the root and index from the checkpoint. + same, err := (Checkpoint{Domain: vectorOriginDomain, Root: root, Index: vectorIndex}). + Metadata(hook, test.signatures) + if err != nil { + t.Fatalf("Checkpoint.Metadata: %v", err) + } + if !bytes.Equal(same, metadata) { + t.Errorf("Checkpoint.Metadata = %x, want %x", same, metadata) + } + }) + } +} + +func TestMetadataRejectsMalformedSignatures(t *testing.T) { + hook := mustAddress(t, vectorHook) + root := mustBytes32(t, vectorRoot) + valid := append(bytes.Repeat([]byte{0x01}, 64), 27) + + highS := append(bytes.Repeat([]byte{0x01}, 32), bytes.Repeat([]byte{0xff}, 32)...) + highS = append(highS, 27) + zeroR := append(make([]byte, 32), bytes.Repeat([]byte{0x02}, 32)...) + zeroR = append(zeroR, 27) + badV := append(bytes.Repeat([]byte{0x01}, 64), 26) + + for _, test := range []struct { + name string + signatures []byte + }{ + {"empty", nil}, + {"truncated", valid[:64]}, + {"overlong", append(append([]byte{}, valid...), 0x00)}, + {"high-s", highS}, + {"zero-r", zeroR}, + {"v-not-27-28", badV}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := Metadata(hook, root, vectorIndex, test.signatures); err == nil { + t.Fatalf("Metadata accepted %s signatures", test.name) + } + }) + } + + metadata, err := Metadata(hook, root, vectorIndex, valid) + if err != nil { + t.Fatalf("Metadata: %v", err) + } + if _, err := MetadataOriginMerkleTreeHook(metadata[:31]); err == nil { + t.Error("MetadataOriginMerkleTreeHook accepted a short payload") + } + if _, err := MetadataRoot(metadata[:63]); err == nil { + t.Error("MetadataRoot accepted a short payload") + } + if _, err := MetadataIndex(metadata[:67]); err == nil { + t.Error("MetadataIndex accepted a short payload") + } + if _, err := MetadataSignatureAt(metadata, 1); err == nil { + t.Error("MetadataSignatureAt accepted an index past the signatures") + } + if _, err := MetadataSignatureAt(metadata, -1); err == nil { + t.Error("MetadataSignatureAt accepted a negative index") + } +} + +func TestValidatorSignatureRecovery(t *testing.T) { + vectors := loadVectors(t) + signer := fixtureSigner(t, vectors.Constants.FixtureKeys["validator"]) + if got, want := signer.Address(), mustAddress(t, vectorValidatorAddress); got != want { + t.Fatalf("fixture validator address = %s, want %s", got, want) + } + validator, err := NewValidator(signer) + if err != nil { + t.Fatalf("NewValidator: %v", err) + } + if validator.Address() != signer.Address() { + t.Fatalf("Validator.Address = %s, want %s", validator.Address(), signer.Address()) + } + + message := vectorMessageBytes(t) + hook := mustAddress(t, vectorHook) + checkpoint := Checkpoint{Domain: vectorOriginDomain, Root: mustBytes32(t, vectorRoot), Index: vectorIndex} + signature, err := validator.SignMessage(hook, checkpoint, message) + if err != nil { + t.Fatalf("SignMessage: %v", err) + } + if len(signature) != SignatureLength { + t.Fatalf("signature length %d, want %d", len(signature), SignatureLength) + } + if v := signature[64]; v != 27 && v != 28 { + t.Fatalf("signature v = %d, want 27 or 28", v) + } + + digest := checkpoint.Digest(hook, MessageID(message)) + recovered := recoverAddress(t, digest, signature) + if recovered != signer.Address() { + t.Fatalf("recovered %s, want %s", recovered, signer.Address()) + } + // The signature is over the prefixed digest: recovering over the unprefixed + // signing hash must not yield the validator, which is what would happen if + // the EIP-191 prefixing were dropped. + signingHash := checkpoint.Hash(hook, MessageID(message)) + if fromSigningHash := recoverAddress(t, signingHash, signature); fromSigningHash == signer.Address() { + t.Fatal("signature recovers over the unprefixed hash; the digest must be prefixed") + } + + // The signature is exactly what lands in the metadata, and it is the same + // statement SignCheckpoint makes for the already-derived message id. + direct, err := validator.SignCheckpoint(hook, checkpoint, MessageID(message)) + if err != nil { + t.Fatalf("SignCheckpoint: %v", err) + } + if !bytes.Equal(direct, signature) { + t.Fatalf("SignCheckpoint = %x, want %x", direct, signature) + } + metadata, err := Metadata(hook, checkpoint.Root, checkpoint.Index, signature) + if err != nil { + t.Fatalf("Metadata with a real signature: %v", err) + } + carried, err := MetadataSignatureAt(metadata, 0) + if err != nil { + t.Fatalf("MetadataSignatureAt: %v", err) + } + if !bytes.Equal(carried, signature) { + t.Fatalf("metadata signature = %x, want %x", carried, signature) + } + + // A checkpoint for a different origin can never verify: the ISM takes the + // domain from the message, the relayer from the metadata. + wrongOrigin := checkpoint + wrongOrigin.Domain = vectorOriginDomain + 1 + if _, err := validator.SignMessage(hook, wrongOrigin, message); err == nil { + t.Error("SignMessage accepted a checkpoint whose domain is not the message origin") + } + if _, err := validator.SignMessage(hook, checkpoint, message[:HeaderLength-1]); err == nil { + t.Error("SignMessage accepted a truncated message") + } +} + +func TestEvidenceHashVectors(t *testing.T) { + vectors := loadVectors(t) + if len(vectors.HyperlaneEvidence) == 0 { + t.Fatal("testdata/vectors.json has no hyperlane_evidence vectors") + } + for _, vector := range vectors.HyperlaneEvidence { + sender := mustBytes32(t, vector.Sender) + body := mustHex(t, vector.Body) + got := EvidenceHash(vector.Domain, sender, body) + if got != mustHash(t, vector.EvidenceHash) { + t.Errorf( + "EvidenceHash(domain=%d, hop=%d) = %s, want %s", + vector.Domain, vector.HopIndex, got, vector.EvidenceHash, + ) + } + } + // Boundary case: an empty body still encodes three head words plus a zero + // length word, and the whole encoding is 128 bytes. The expected hash is cast + // keccak of that preimage. + if got, want := EvidenceHash(1, [32]byte{}, nil), + mustHash(t, "0xaa1482fe4a9e0d735888c5ff68d68ed6d50cfe150731168d452902a0df6a2009"); got != want { + t.Errorf("EvidenceHash with an empty body = %s, want %s", got, want) + } +} + +func TestEventTopics(t *testing.T) { + for _, test := range []struct { + name string + got common.Hash + want string + }{ + {"Dispatch", DispatchTopic, "0x769f711d20c679153d382254f59892613b58a97cc876b249134ac25c80f9c814"}, + {"DispatchId", DispatchIDTopic, "0x788dbc1b7152732178210e7f4d9d010ef016f9eafbe66786bd7169f56e0c353a"}, + {"InsertedIntoTree", InsertedIntoTreeTopic, "0x253a3a04cab70d47c1504809242d9350cd81627b4f1d50753e159cf8cd76ed33"}, + {"Process", ProcessTopic, "0x0d381c2a574ae8f04e213db7cfb4df8df712cdbd427d9868ffef380660ca6574"}, + {"ProcessId", ProcessIDTopic, "0x1cae38cdd3d3919489272725a5ae62a4f48b2989b0dae843d3c279fee18073a9"}, + } { + if test.got != mustHash(t, test.want) { + t.Errorf("%sTopic = %s, want %s", test.name, test.got, test.want) + } + } +} + +func TestPlanProcessCall(t *testing.T) { + vectors := loadVectors(t) + if len(vectors.HyperlaneEvidence) == 0 { + t.Fatal("testdata/vectors.json has no hyperlane_evidence vectors") + } + signer := fixtureSigner(t, vectors.Constants.FixtureKeys["validator"]) + validator, err := NewValidator(signer) + if err != nil { + t.Fatalf("NewValidator: %v", err) + } + + // A realistic hop: the dispatched message carries the XIR bundle body the + // Python runner encodes (multihop_runner.py:1167-1200). + body := mustHex(t, vectors.HyperlaneEvidence[0].Body) + var sender, recipient [32]byte + copy(sender[:], mustHex(t, vectors.HyperlaneEvidence[0].Sender)) + recipientAddress := mustAddress(t, "0x00000000000000000000000000000000000000cc") + copy(recipient[12:], recipientAddress.Bytes()) + message := FormatMessage(Version, 3, vectorOriginDomain, sender, 31338, recipient, body) + + hook := mustAddress(t, vectorHook) + checkpoint := Checkpoint{Domain: vectorOriginDomain, Root: mustBytes32(t, vectorRoot), Index: vectorIndex} + signature, err := validator.SignMessage(hook, checkpoint, message) + if err != nil { + t.Fatalf("SignMessage: %v", err) + } + mailbox := mustAddress(t, "0x00000000000000000000000000000000000000ab") + + call, err := Plan(PlanRequest{ + Message: message, + OriginMerkleTreeHook: hook, + Checkpoint: checkpoint, + Signatures: signature, + DestinationMailbox: mailbox, + }) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if call.Mailbox != mailbox { + t.Errorf("Mailbox = %s, want %s", call.Mailbox, mailbox) + } + if call.MessageID != MessageID(message) { + t.Errorf("MessageID = %s, want %s", call.MessageID, MessageID(message)) + } + if !bytes.Equal(call.Message, message) { + t.Errorf("Message = %x, want %x", call.Message, message) + } + wantMetadata, err := Metadata(hook, checkpoint.Root, checkpoint.Index, signature) + if err != nil { + t.Fatalf("Metadata: %v", err) + } + if !bytes.Equal(call.Metadata, wantMetadata) { + t.Errorf("Metadata = %x, want %x", call.Metadata, wantMetadata) + } + if got := hex.EncodeToString(call.Calldata[:4]); "0x"+got != vectorProcessSelector { + t.Fatalf("selector = 0x%s, want %s", got, vectorProcessSelector) + } + + contract, err := MailboxABI() + if err != nil { + t.Fatalf("MailboxABI: %v", err) + } + method, ok := contract.ABI().Methods["process"] + if !ok { + t.Fatal("Mailbox ABI has no process method") + } + if got := "0x" + hex.EncodeToString(method.ID); got != vectorProcessSelector { + t.Errorf("ABI method id = %s, want %s", got, vectorProcessSelector) + } + arguments, err := method.Inputs.Unpack(call.Calldata[4:]) + if err != nil { + t.Fatalf("unpack process calldata: %v", err) + } + if len(arguments) != 2 { + t.Fatalf("process calldata carries %d arguments, want 2", len(arguments)) + } + if !bytes.Equal(arguments[0].([]byte), call.Metadata) { + t.Errorf("calldata metadata = %x, want %x", arguments[0].([]byte), call.Metadata) + } + if !bytes.Equal(arguments[1].([]byte), message) { + t.Errorf("calldata message = %x, want %x", arguments[1].([]byte), message) + } +} + +func TestPlanRejectsInconsistentInputs(t *testing.T) { + hook := mustAddress(t, vectorHook) + signature := append(bytes.Repeat([]byte{0x01}, 64), 27) + message := vectorMessageBytes(t) + checkpoint := Checkpoint{Domain: vectorOriginDomain, Root: mustBytes32(t, vectorRoot), Index: vectorIndex} + mailbox := mustAddress(t, "0x00000000000000000000000000000000000000ab") + + for _, test := range []struct { + name string + request PlanRequest + }{ + {"truncated-message", PlanRequest{ + Message: message[:HeaderLength-1], OriginMerkleTreeHook: hook, + Checkpoint: checkpoint, Signatures: signature, DestinationMailbox: mailbox, + }}, + {"wrong-version", PlanRequest{ + Message: FormatMessage(2, 1, vectorOriginDomain, [32]byte{}, 31338, [32]byte{}, nil), + OriginMerkleTreeHook: hook, Checkpoint: checkpoint, Signatures: signature, + DestinationMailbox: mailbox, + }}, + {"domain-mismatch", PlanRequest{ + Message: message, OriginMerkleTreeHook: hook, + Checkpoint: Checkpoint{Domain: vectorOriginDomain + 1, Root: checkpoint.Root, Index: checkpoint.Index}, + Signatures: signature, DestinationMailbox: mailbox, + }}, + {"zero-mailbox", PlanRequest{ + Message: message, OriginMerkleTreeHook: hook, Checkpoint: checkpoint, + Signatures: signature, + }}, + {"bad-signature", PlanRequest{ + Message: message, OriginMerkleTreeHook: hook, Checkpoint: checkpoint, + Signatures: signature[:64], DestinationMailbox: mailbox, + }}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := Plan(test.request); err == nil { + t.Fatalf("Plan accepted %s", test.name) + } + }) + } +} + +func TestDispatchedMessage(t *testing.T) { + contract, err := MailboxABI() + if err != nil { + t.Fatalf("MailboxABI: %v", err) + } + sender := mustAddress(t, "0x00000000000000000000000000000000000000dd") + message := vectorMessageBytes(t) + log := dispatchLog(t, contract, sender, 31338, message) + + receipt := &types.Receipt{ + TxHash: mustHash(t, vectorMessageID), + Logs: []*types.Log{log}, + } + dispatched, err := DispatchedMessage(receipt, contract) + if err != nil { + t.Fatalf("DispatchedMessage: %v", err) + } + if !bytes.Equal(dispatched.Message, message) { + t.Errorf("Message = %x, want %x", dispatched.Message, message) + } + if dispatched.MessageID != mustHash(t, vectorMessageID) { + t.Errorf("MessageID = %s, want %s", dispatched.MessageID, vectorMessageID) + } + if dispatched.Nonce != 1 { + t.Errorf("Nonce = %d, want 1", dispatched.Nonce) + } + if dispatched.Destination != 31338 { + t.Errorf("Destination = %d, want 31338", dispatched.Destination) + } + if dispatched.Sender != sender { + t.Errorf("Sender = %s, want %s", dispatched.Sender, sender) + } + if got, want := dispatched.Recipient, Message(message).Recipient(); got != want { + t.Errorf("Recipient = %x, want %x", got, want) + } + + // A receipt from any other transaction, and a receipt whose dispatch log has + // the wrong topic layout, are both errors rather than empty results. + if _, err := DispatchedMessage(&types.Receipt{}, contract); err == nil { + t.Error("DispatchedMessage accepted a receipt without a Dispatch log") + } + truncated := *log + truncated.Topics = log.Topics[:3] + if _, err := DispatchedMessage(&types.Receipt{Logs: []*types.Log{&truncated}}, contract); err == nil { + t.Error("DispatchedMessage accepted a Dispatch log with 3 topics") + } + if _, err := DispatchedMessage(nil, contract); err == nil { + t.Error("DispatchedMessage accepted a nil receipt") + } +} + +// dispatchLog builds the Mailbox `Dispatch` log of one message with the topic +// layout solidity/contracts/interfaces/IMailbox.sol:16-21 declares. +func dispatchLog( + t *testing.T, + contract *abiutil.Contract, + sender common.Address, + destination uint32, + message []byte, +) *types.Log { + t.Helper() + event, ok := contract.ABI().Events["Dispatch"] + if !ok { + t.Fatal("Mailbox ABI has no Dispatch event") + } + data, err := event.Inputs.NonIndexed().Pack(message) + if err != nil { + t.Fatalf("pack Dispatch data: %v", err) + } + recipient := Message(message).Recipient() + return &types.Log{ + Address: mustAddress(t, "0x00000000000000000000000000000000000000ab"), + Topics: []common.Hash{ + DispatchTopic, + common.BytesToHash(common.LeftPadBytes(sender.Bytes(), 32)), + common.BigToHash(new(big.Int).SetUint64(uint64(destination))), + common.Hash(recipient), + }, + Data: data, + } +} + +type vectorDocument struct { + Constants struct { + FixtureKeys map[string]string `json:"fixture_keys"` + } `json:"constants"` + HyperlaneEvidence []struct { + HopIndex int `json:"hop_index"` + Domain uint32 `json:"domain"` + Sender string `json:"sender"` + Body string `json:"body"` + EvidenceHash string `json:"evidence_hash"` + } `json:"hyperlane_evidence"` +} + +func loadVectors(t *testing.T) vectorDocument { + t.Helper() + path := filepath.Join("..", "..", "testdata", "vectors.json") + payload, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var document vectorDocument + if err := json.Unmarshal(payload, &document); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return document +} + +// fixtureSigner builds the personal-message signer shape this package expects +// from internal/evm's Signer: EIP-191 prefixing over the 32-byte digest, then +// ECDSA with go-ethereum's own recovery id convention. +func fixtureSigner(t *testing.T, privateKeyHex string) testSigner { + t.Helper() + if privateKeyHex == "" { + t.Fatal("testdata/vectors.json has no constants.fixture_keys.validator") + } + key, err := crypto.HexToECDSA(privateKeyHex[2:]) + if err != nil { + t.Fatalf("parse fixture key: %v", err) + } + return testSigner{key: key} +} + +type testSigner struct { + key *ecdsa.PrivateKey +} + +// recoverAddress recovers the signer of one digest, converting the metadata +// signature's {27,28} recovery id to the {0,1} convention go-ethereum's +// `crypto.Ecrecover` expects. +func recoverAddress(t *testing.T, digest [32]byte, signature []byte) common.Address { + t.Helper() + recoveryID := append([]byte(nil), signature...) + if recoveryID[64] >= 27 { + recoveryID[64] -= 27 + } + publicKey, err := crypto.Ecrecover(digest[:], recoveryID) + if err != nil { + t.Fatalf("Ecrecover: %v", err) + } + unmarshalled, err := crypto.UnmarshalPubkey(publicKey) + if err != nil { + t.Fatalf("UnmarshalPubkey: %v", err) + } + return crypto.PubkeyToAddress(*unmarshalled) +} + +func (s testSigner) Address() common.Address { + return crypto.PubkeyToAddress(s.key.PublicKey) +} + +func (s testSigner) SignPersonalDigest(digest [32]byte) ([]byte, error) { + prefixed := crypto.Keccak256Hash( + append([]byte("\x19Ethereum Signed Message:\n32"), digest[:]...), + ) + return crypto.Sign(prefixed[:], s.key) +} diff --git a/go-runtime/internal/hyperlane/message.go b/go-runtime/internal/hyperlane/message.go new file mode 100644 index 0000000..370cbe1 --- /dev/null +++ b/go-runtime/internal/hyperlane/message.go @@ -0,0 +1,174 @@ +package hyperlane + +import ( + "encoding/binary" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// Message layout offsets, in bytes from the start of the packed message. +// +// solidity/contracts/libs/Message.sol:13-19. +const ( + VersionOffset = 0 + NonceOffset = 1 + OriginOffset = 5 + SenderOffset = 9 + DestinationOffset = 41 + RecipientOffset = 45 + BodyOffset = 77 +) + +// HeaderLength is the size of every Hyperlane message before its body. +// +// solidity/contracts/libs/Message.sol:18-19; the Rust encoder agrees with +// `HYPERLANE_MESSAGE_PREFIX_LEN = 77` +// (rust/main/hyperlane-core/src/types/message.rs:11). +const HeaderLength = BodyOffset + +// Message is one packed Hyperlane message: seven fields concatenated with no +// length prefix and no padding. +// +// The type is a plain byte slice, so a decoded message read back from a +// `Dispatch` log or from calldata can be reinterpreted with a conversion. Every +// accessor is total: a slice shorter than the field it covers yields the zero +// value rather than panicking, and Validate reports the malformed length. +type Message []byte + +// FormatMessage packs one Hyperlane message. +// +// solidity/contracts/libs/Message.sol:33-52 — `formatMessage` is +// `abi.encodePacked(version, nonce, origin, sender, destination, recipient, +// body)`, so the parameters are mirrored here in the contract's order: +// +// version uint8 1 byte at offset 0 +// nonce uint32 4 bytes at offset 1 big-endian +// origin uint32 4 bytes at offset 5 big-endian +// sender bytes32 32 bytes at offset 9 left-padded address +// destination uint32 4 bytes at offset 41 big-endian +// recipient bytes32 32 bytes at offset 45 left-padded address +// body bytes remaining, from offset 77, no padding +// +// The origin Mailbox builds the same bytes in Mailbox.sol:434-457 with +// `VERSION`, its own `nonce` and `localDomain`, and `msg.sender` as the sender. +func FormatMessage( + version uint8, + nonce uint32, + origin uint32, + sender [32]byte, + destination uint32, + recipient [32]byte, + body []byte, +) []byte { + message := make([]byte, 0, HeaderLength+len(body)) + message = append(message, version) + message = binary.BigEndian.AppendUint32(message, nonce) + message = binary.BigEndian.AppendUint32(message, origin) + message = append(message, sender[:]...) + message = binary.BigEndian.AppendUint32(message, destination) + message = append(message, recipient[:]...) + return append(message, body...) +} + +// MessageID returns the id of a packed message. +// +// solidity/contracts/libs/Message.sol:59-61 — `keccak256(_message)`, computed +// over the raw packed bytes with no domain separator and no length prefix. The +// Mailbox emits this value as `DispatchId` (solidity/contracts/Mailbox.sol:300-303) +// and MerkleTreeHook inserts it as a tree leaf +// (solidity/contracts/hooks/MerkleTreeHook.sol:70-76). +func MessageID(message []byte) [32]byte { + return xir.Keccak256(message) +} + +// Validate reports whether the byte slice is long enough to be a message. +func (m Message) Validate() error { + if len(m) < BodyOffset { + return fmt.Errorf( + "hyperlane: message length %d shorter than the %d-byte header", len(m), BodyOffset, + ) + } + return nil +} + +// ID returns the id of the message. +func (m Message) ID() [32]byte { + return MessageID(m) +} + +// Version returns the message version (Message.sol:68-70). +func (m Message) Version() uint8 { + if len(m) < NonceOffset { + return 0 + } + return m[VersionOffset] +} + +// Nonce returns the message nonce (Message.sol:77-79). +func (m Message) Nonce() uint32 { + if len(m) < OriginOffset { + return 0 + } + return binary.BigEndian.Uint32(m[NonceOffset:OriginOffset]) +} + +// Origin returns the origin domain (Message.sol:86-88). +func (m Message) Origin() uint32 { + if len(m) < SenderOffset { + return 0 + } + return binary.BigEndian.Uint32(m[OriginOffset:SenderOffset]) +} + +// Sender returns the sender as bytes32 (Message.sol:95-97). +func (m Message) Sender() [32]byte { + var sender [32]byte + if len(m) < DestinationOffset { + return sender + } + copy(sender[:], m[SenderOffset:DestinationOffset]) + return sender +} + +// SenderAddress returns the sender as an address and rejects a sender whose +// upper 96 bits are non-zero (Message.sol:105-110 via TypeCasts.sol:11-17). +func (m Message) SenderAddress() (common.Address, error) { + return Bytes32ToAddress(m.Sender()) +} + +// Destination returns the destination domain (Message.sol:115-119). +func (m Message) Destination() uint32 { + if len(m) < RecipientOffset { + return 0 + } + return binary.BigEndian.Uint32(m[DestinationOffset:RecipientOffset]) +} + +// Recipient returns the recipient as bytes32 (Message.sol:126-130). +func (m Message) Recipient() [32]byte { + var recipient [32]byte + if len(m) < BodyOffset { + return recipient + } + copy(recipient[:], m[RecipientOffset:BodyOffset]) + return recipient +} + +// RecipientAddress returns the recipient as an address and rejects a recipient +// whose upper 96 bits are non-zero (Message.sol:137-142, the address +// `Mailbox.process` calls `handle` on). +func (m Message) RecipientAddress() (common.Address, error) { + return Bytes32ToAddress(m.Recipient()) +} + +// Body returns the message body, i.e. everything from offset 77, as a view into +// the message (Message.sol:148-152). The result aliases the receiver. +func (m Message) Body() []byte { + if len(m) < BodyOffset { + return nil + } + return m[BodyOffset:] +} diff --git a/go-runtime/internal/hyperlane/metadata.go b/go-runtime/internal/hyperlane/metadata.go new file mode 100644 index 0000000..2c63c8f --- /dev/null +++ b/go-runtime/internal/hyperlane/metadata.go @@ -0,0 +1,234 @@ +package hyperlane + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// MessageIdMultisigIsm metadata layout, in bytes from the start of the metadata. +// +// solidity/contracts/isms/libs/MessageIdMultisigIsmMetadata.sol:5-16: +// +// [ 0: 32] Origin merkle tree address +// [ 32: 64] Signed checkpoint root +// [ 64: 68] Signed checkpoint index +// [ 68:????] Validator signatures (length := threshold * 65) +// +// There is no merkle proof in this module: the ISM only re-hashes the signed +// checkpoint, so the root and index fields are copied verbatim from what the +// validators signed and any tampering changes the recovered signer. +const ( + MetadataOriginMerkleTreeHookOffset = 0 + MetadataRootOffset = 32 + MetadataIndexOffset = 64 + MetadataSignaturesOffset = 68 + + // SignatureLength is the length of one validator signature: r || s || v, as + // MessageIdMultisigIsmMetadata.sol:16 and the Solidity test's + // `abi.encodePacked(metadata, r, s, v)` (solidity/test/isms/MultisigIsm.t.sol:119-124). + SignatureLength = 65 +) + +// secp256k1HalfOrder is the largest `s` OpenZeppelin's ECDSA accepts; a higher +// value reverts with "ECDSA: invalid signature 's' value" +// (openzeppelin-contracts v4.9.3 utils/cryptography/ECDSA.sol:23-33, enforced in +// `tryRecover` at :124-136). +var secp256k1HalfOrder = [32]byte{ + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, + 0xdf, 0xe9, 0x2f, 0x46, 0x68, 0x1b, 0x20, 0xa0, +} + +// MetadataLength returns the size of MessageIdMultisigIsm metadata for a +// threshold of `threshold` validators: 68 + 65*threshold. +// +// MessageIdMultisigIsmMetadata.sol:15-16; the Rust relayer emits exactly these +// four tokens in this order +// (rust/main/agents/relayer/src/msg/metadata/multisig/message_id_multisig.rs:25-31). +// A negative threshold describes no signature list, so it is clamped to the +// 68-byte header instead of shrinking the result. +func MetadataLength(threshold int) int { + if threshold < 0 { + return MetadataSignaturesOffset + } + return MetadataSignaturesOffset + SignatureLength*threshold +} + +// SignatureCount returns how many signatures a signature blob carries. +// +// This is the rule of +// solidity/contracts/isms/libs/MessageIdMultisigIsmMetadata.sol:77-83 +// (`(metadata.length - 68) / 65`, reverting with "Invalid signatures length" when +// the remainder is non-zero) applied to the signature blob alone, i.e. to +// metadata without its 68-byte header. +func SignatureCount(signatures []byte) (int, error) { + if remainder := len(signatures) % SignatureLength; remainder != 0 { + return 0, fmt.Errorf( + "hyperlane: signatures length %d is %d bytes past a signature boundary", + len(signatures), remainder, + ) + } + return len(signatures) / SignatureLength, nil +} + +// Metadata builds the metadata for one signed checkpoint. +// +// The bytes are exactly `hook[32] || root[32] || uint32 index[4] || signatures`, +// each signature `r || s || v` with `v` in {27,28} in the order of the ISM's +// validator array: `verify` walks the validator array and the signature list with +// two pointers and fails on an out-of-order or duplicate signature +// (solidity/contracts/isms/multisig/AbstractMultisigIsm.sol:106-121). +// +// The validator set and threshold are not inputs here: for the static ISM they +// live in the ISM's own MetaProxy code +// (solidity/contracts/isms/multisig/StaticMultisigIsm.sol:17-25), and the ISM +// reads exactly `threshold` signatures, so trailing signatures are ignored +// (`verify` never calls `signatureCount`). +// +// signatures must be a whole number of 65-byte, low-`s`, `v`-normalized +// signatures; anything else is rejected here rather than at `process`, where the +// only feedback would be a reverted destination transaction. +func Metadata( + originMerkleTreeHook common.Address, + root [32]byte, + index uint32, + signatures []byte, +) ([]byte, error) { + count, err := SignatureCount(signatures) + if err != nil { + return nil, err + } + if count == 0 { + return nil, errors.New( + "hyperlane: metadata needs at least one validator signature " + + "(the ISM requires threshold > 0)", + ) + } + if err := validateSignatures(signatures, count); err != nil { + return nil, err + } + hook := AddressToBytes32(originMerkleTreeHook) + metadata := make([]byte, 0, MetadataLength(count)) + metadata = append(metadata, hook[:]...) + metadata = append(metadata, root[:]...) + metadata = binary.BigEndian.AppendUint32(metadata, index) + return append(metadata, signatures...), nil +} + +// Metadata builds the metadata of one checkpoint, taking the tree address and +// the root and index from the checkpoint itself. +func (c Checkpoint) Metadata( + originMerkleTreeHook common.Address, + signatures []byte, +) ([]byte, error) { + return Metadata(originMerkleTreeHook, c.Root, c.Index, signatures) +} + +// MetadataOriginMerkleTreeHook returns metadata field [0:32] as bytes32. +// +// MessageIdMultisigIsmMetadata.sol:23-27. This is the origin MerkleTreeHook +// address the validators signed for; it is left-padded, so it round-trips +// through Bytes32ToAddress whenever the upper 96 bits are zero. +func MetadataOriginMerkleTreeHook(metadata []byte) ([32]byte, error) { + var hook [32]byte + if err := requireLength(metadata, MetadataRootOffset, "origin merkle tree hook"); err != nil { + return hook, err + } + copy(hook[:], metadata[MetadataOriginMerkleTreeHookOffset:MetadataRootOffset]) + return hook, nil +} + +// MetadataRoot returns metadata field [32:64], the signed checkpoint root +// (MessageIdMultisigIsmMetadata.sol:38-40). +func MetadataRoot(metadata []byte) ([32]byte, error) { + var root [32]byte + if err := requireLength(metadata, MetadataIndexOffset, "merkle root"); err != nil { + return root, err + } + copy(root[:], metadata[MetadataRootOffset:MetadataIndexOffset]) + return root, nil +} + +// MetadataIndex returns metadata field [64:68], the signed checkpoint leaf index +// (MessageIdMultisigIsmMetadata.sol:47-51). +func MetadataIndex(metadata []byte) (uint32, error) { + if err := requireLength(metadata, MetadataSignaturesOffset, "merkle index"); err != nil { + return 0, err + } + return binary.BigEndian.Uint32(metadata[MetadataIndexOffset:MetadataSignaturesOffset]), nil +} + +// MetadataSignatureAt returns the 65-byte signature at index, the view +// `AbstractMultisigIsm.verify` recovers a validator from +// (MessageIdMultisigIsmMetadata.sol:63-70). +func MetadataSignatureAt(metadata []byte, index int) ([]byte, error) { + if index < 0 { + return nil, fmt.Errorf("hyperlane: signature index %d is negative", index) + } + start := MetadataSignaturesOffset + index*SignatureLength + if err := requireLength(metadata, start+SignatureLength, "signature"); err != nil { + return nil, err + } + return metadata[start : start+SignatureLength], nil +} + +// requireLength reports a metadata payload that is too short for one field, +// mirroring the calldata slice bounds `MessageIdMultisigIsmMetadata` relies on. +func requireLength(metadata []byte, length int, field string) error { + if len(metadata) < length { + return fmt.Errorf( + "hyperlane: metadata length %d cannot hold the %s field (%d bytes)", + len(metadata), field, length, + ) + } + return nil +} + +// validateSignatures rejects anything the on-chain recovery would reject, so a +// malformed relayer action fails before it is broadcast. +func validateSignatures(signatures []byte, count int) error { + for index := 0; index < count; index++ { + signature := signatures[index*SignatureLength : (index+1)*SignatureLength] + var r, s [32]byte + copy(r[:], signature[:32]) + copy(s[:], signature[32:64]) + if r == ([32]byte{}) || s == ([32]byte{}) { + return fmt.Errorf("hyperlane: signature %d has a zero r or s value", index) + } + if bytes.Compare(s[:], secp256k1HalfOrder[:]) > 0 { + return fmt.Errorf( + "hyperlane: signature %d has a high s value (not low-s canonical)", index, + ) + } + if v := signature[64]; v != 27 && v != 28 { + return fmt.Errorf("hyperlane: signature %d has v %d, want 27 or 28", index, v) + } + } + return nil +} + +// normalizeSignature converts a raw secp256k1 signature into the r || s || v form +// the ISM recovers: it shifts `v` from the {0,1} convention go-ethereum's +// `crypto.Sign` uses to the {27,28} convention OZ `ECDSA.recover` requires +// (openzeppelin-contracts v4.9.3 ECDSA.sol:88-92 rejects any other value), and +// refuses a signature that would not recover on chain. +func normalizeSignature(signature []byte) ([]byte, error) { + if len(signature) != SignatureLength { + return nil, fmt.Errorf( + "hyperlane: signature length %d, want %d", len(signature), SignatureLength, + ) + } + normalized := append([]byte(nil), signature...) + if normalized[64] < 27 { + normalized[64] += 27 + } + if err := validateSignatures(normalized, 1); err != nil { + return nil, err + } + return normalized, nil +} diff --git a/go-runtime/internal/hyperlane/receipt.go b/go-runtime/internal/hyperlane/receipt.go new file mode 100644 index 0000000..6663674 --- /dev/null +++ b/go-runtime/internal/hyperlane/receipt.go @@ -0,0 +1,112 @@ +package hyperlane + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +// Dispatched is a decoded Mailbox `Dispatch` log. +type Dispatched struct { + // Sender is the origin adapter that called `dispatch` (the Mailbox emits + // `msg.sender`, solidity/contracts/Mailbox.sol:302 and :434-457), i.e. the + // address half of the evidence sender field. + Sender common.Address + + // Destination is the destination domain of the message. + Destination uint32 + + // Recipient is the destination adapter as bytes32. + Recipient [32]byte + + // Message is the raw dispatched message. + Message []byte + + // MessageID is keccak256(Message), the value the origin MerkleTreeHook + // inserted as a leaf and `DispatchId` carries. + MessageID [32]byte + + // Nonce is the origin Mailbox nonce of the message. + Nonce uint32 +} + +// DispatchedMessage returns the first Mailbox `Dispatch` log of a receipt with +// its message, message id, nonce and destination domain. +// +// contract is a Mailbox ABI (MailboxABI, or the Hyperlane artifact's ABI); the +// log is matched by its `Dispatch(address,uint32,bytes32,bytes)` topic, which is +// only emitted by a Mailbox, so the origin mailbox address is not required. The +// first match wins, which is the rule the Python reference uses when it reads a +// dispatch receipt (src/xir_lab/native/multihop_runner.py:1230-1239); a receipt +// without a dispatch log is an error, not an empty result. +// +// Note that the XIR adapters re-emit the id themselves: the Python runner takes +// `native_message_id` from the adapter's `HyperlaneDispatched` topic, which is a +// different (adapter) event on the same receipt. +func DispatchedMessage(receipt *types.Receipt, contract *abiutil.Contract) (Dispatched, error) { + if receipt == nil { + return Dispatched{}, errors.New("hyperlane: nil receipt") + } + if contract == nil { + return Dispatched{}, errors.New("hyperlane: nil Mailbox ABI") + } + event, ok := contract.ABI().Events["Dispatch"] + if !ok { + return Dispatched{}, errors.New("hyperlane: Mailbox ABI has no Dispatch event") + } + for _, log := range receipt.Logs { + if log == nil || len(log.Topics) == 0 || log.Topics[0] != event.ID { + continue + } + values, err := contract.UnpackLog("Dispatch", log) + if err != nil { + return Dispatched{}, err + } + sender, err := typedValue[common.Address](values, "sender") + if err != nil { + return Dispatched{}, err + } + destination, err := typedValue[uint32](values, "destination") + if err != nil { + return Dispatched{}, err + } + recipient, err := typedValue[[32]byte](values, "recipient") + if err != nil { + return Dispatched{}, err + } + message, err := typedValue[[]byte](values, "message") + if err != nil { + return Dispatched{}, err + } + return Dispatched{ + Sender: sender, + Destination: destination, + Recipient: recipient, + Message: message, + MessageID: MessageID(message), + Nonce: Message(message).Nonce(), + }, nil + } + return Dispatched{}, fmt.Errorf( + "hyperlane: receipt %s has no Mailbox Dispatch log", receipt.TxHash.Hex(), + ) +} + +// typedValue pulls one named, already decoded event argument out of the map +// abiutil.UnpackLog returns. +func typedValue[T any](values map[string]any, field string) (T, error) { + var zero T + value, ok := values[field] + if !ok { + return zero, fmt.Errorf("hyperlane: Dispatch log lacks the %s field", field) + } + typed, ok := value.(T) + if !ok { + return zero, fmt.Errorf("hyperlane: Dispatch log %s has type %T", field, value) + } + return typed, nil +} diff --git a/go-runtime/internal/hyperlane/relayer.go b/go-runtime/internal/hyperlane/relayer.go new file mode 100644 index 0000000..7efec8a --- /dev/null +++ b/go-runtime/internal/hyperlane/relayer.go @@ -0,0 +1,161 @@ +package hyperlane + +import ( + "errors" + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +// mailboxABI is the Mailbox surface this package packs and decodes with: the +// single relayer entry point `process(bytes,bytes)` +// (solidity/contracts/Mailbox.sol:202-247, declared at +// solidity/contracts/interfaces/IMailbox.sol:103-106) and the `Dispatch` event a +// relayer reads a dispatched message back from (IMailbox.sol:16-21). +// +// It is a fragment rather than a Forge artifact so this package stays usable +// without `contracts/out`; internal/artifacts loads the full artifact when the +// caller has one. +const mailboxABI = `[ + { + "type": "function", + "name": "process", + "stateMutability": "payable", + "inputs": [ + {"name": "_metadata", "type": "bytes"}, + {"name": "_message", "type": "bytes"} + ], + "outputs": [] + }, + { + "type": "event", + "name": "Dispatch", + "anonymous": false, + "inputs": [ + {"name": "sender", "type": "address", "indexed": true}, + {"name": "destination", "type": "uint32", "indexed": true}, + {"name": "recipient", "type": "bytes32", "indexed": true}, + {"name": "message", "type": "bytes", "indexed": false} + ] + } +]` + +var ( + mailboxOnce sync.Once + mailboxContract *abiutil.Contract + mailboxError error +) + +// MailboxABI returns the parsed Mailbox fragment, so callers can pack and decode +// against the same ABI document this package uses. +func MailboxABI() (*abiutil.Contract, error) { + mailboxOnce.Do(func() { + mailboxContract, mailboxError = abiutil.New(mailboxABI) + }) + if mailboxError != nil { + return nil, mailboxError + } + return mailboxContract, nil +} + +// PlanRequest is everything the relayer derives off chain before submitting the +// destination call: the dispatched message, the origin tree the validators +// signed for, the checkpoint they signed, their signatures, and the destination +// Mailbox the call goes to. +type PlanRequest struct { + Message []byte + + // OriginMerkleTreeHook is the origin MerkleTreeHook address, i.e. metadata + // field [0:32] and the tree half of the signing domain. It is not the origin + // Mailbox: the ISM binds the tree address cryptographically and would not + // recover the validators from a mailbox address. + OriginMerkleTreeHook common.Address + + // Checkpoint is the signed checkpoint of this message's leaf: the root and + // index validators signed, plus the origin domain. + Checkpoint Checkpoint + + // Signatures are the validators' 65-byte `r || s || v` signatures, ordered + // as the ISM's validator array is ordered. + Signatures []byte + + // DestinationMailbox is the Mailbox on the destination domain, whose + // `localDomain` must equal the message's destination field. + DestinationMailbox common.Address +} + +// ProcessCall is the one destination-chain action that delivers a dispatched hop. +type ProcessCall struct { + // Mailbox is the destination Mailbox the call is sent to. + Mailbox common.Address + + // Message is the dispatched message, submitted verbatim. + Message []byte + + // MessageID is keccak256(Message); the destination Mailbox refuses a second + // delivery of the same id (Mailbox.sol:216-217). + MessageID [32]byte + + // Metadata is the ISM metadata paired with the message. + Metadata []byte + + // Calldata is `process(bytes,bytes)` with the metadata and the message: the + // calldata of the destination transaction, and the only argument the + // destination chain is called with. + Calldata []byte +} + +// Plan derives the destination `process` call for one dispatched hop. +// +// The plan is pure: it validates the inputs the relayer already holds and packs +// the calldata. Everything that needs chain state stays on the caller's side — +// the origin tree root and leaf index come from the origin MerkleTreeHook +// (`root()`, `count()`, or the `InsertedIntoTree` log of this dispatch), and the +// destination Mailbox's own `localDomain` check happens on chain. +func Plan(request PlanRequest) (ProcessCall, error) { + message := Message(request.Message) + if err := message.Validate(); err != nil { + return ProcessCall{}, err + } + if version := message.Version(); version != Version { + return ProcessCall{}, fmt.Errorf( + "hyperlane: message version %d, want %d (Mailbox: bad version)", version, Version, + ) + } + if origin := message.Origin(); origin != request.Checkpoint.Domain { + return ProcessCall{}, fmt.Errorf( + "hyperlane: checkpoint domain %d does not match message origin %d", + request.Checkpoint.Domain, origin, + ) + } + if request.DestinationMailbox == (common.Address{}) { + return ProcessCall{}, errors.New("hyperlane: destination mailbox is the zero address") + } + metadata, err := Metadata( + request.OriginMerkleTreeHook, + request.Checkpoint.Root, + request.Checkpoint.Index, + request.Signatures, + ) + if err != nil { + return ProcessCall{}, err + } + contract, err := MailboxABI() + if err != nil { + return ProcessCall{}, err + } + calldata, err := contract.PackCall("process", metadata, request.Message) + if err != nil { + return ProcessCall{}, err + } + return ProcessCall{ + Mailbox: request.DestinationMailbox, + Message: request.Message, + MessageID: MessageID(request.Message), + Metadata: metadata, + Calldata: calldata, + }, nil +} diff --git a/go-runtime/internal/hyperlane/validator.go b/go-runtime/internal/hyperlane/validator.go new file mode 100644 index 0000000..a29223b --- /dev/null +++ b/go-runtime/internal/hyperlane/validator.go @@ -0,0 +1,91 @@ +package hyperlane + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// PersonalSigner is the signing surface a Hyperlane validator needs: an address +// and EIP-191 personal-message signing of a 32-byte digest. +// +// internal/evm's `*Signer` implements this (`SignPersonalDigest` is documented as +// identical to `eth_account.messages.encode_defunct`), which is exactly the +// transform `CheckpointLib.digest` bakes into the ISM digest: the validators in +// the lab are ordinary EOA keys signing the checkpoint hash with `eth_sign`. +type PersonalSigner interface { + Address() common.Address + SignPersonalDigest(digest [32]byte) ([]byte, error) +} + +// Validator signs origin checkpoints for one key. +type Validator struct { + signer PersonalSigner +} + +// NewValidator wraps a signer. The key material stays with the signer; this +// package never sees or records it. +func NewValidator(signer PersonalSigner) (*Validator, error) { + if signer == nil { + return nil, errors.New("hyperlane: validator needs a signer") + } + return &Validator{signer: signer}, nil +} + +// Address returns the validator address the ISM must list in its validator set. +func (v *Validator) Address() common.Address { + return v.signer.Address() +} + +// SignCheckpoint signs the checkpoint of one message id and returns the 65-byte +// `r || s || v` signature the ISM metadata carries. +// +// The signed hash is `Checkpoint.Hash(originMerkleTreeHook, messageID)`, so the +// signature recovers from `Checkpoint.Digest` on chain +// (AbstractMessageIdMultisigIsm.sol:29-41, AbstractMultisigIsm.sol:110). +func (v *Validator) SignCheckpoint( + originMerkleTreeHook common.Address, + checkpoint Checkpoint, + messageID [32]byte, +) ([]byte, error) { + signature, err := v.signer.SignPersonalDigest( + checkpoint.Hash(originMerkleTreeHook, messageID), + ) + if err != nil { + return nil, fmt.Errorf("hyperlane: sign checkpoint: %w", err) + } + normalized, err := normalizeSignature(signature) + if err != nil { + return nil, fmt.Errorf("hyperlane: sign checkpoint: %w", err) + } + return normalized, nil +} + +// SignMessage signs the checkpoint of one dispatched message and returns the +// 65-byte `r || s || v` signature the ISM metadata carries. +// +// The checkpoint's Domain must be the message's origin domain: the ISM takes the +// domain half of the digest from the message itself +// (AbstractMessageIdMultisigIsm.sol:35, `_message.origin()`) while the relayer +// takes the tree half from the metadata, so a checkpoint signed for a different +// origin could never verify. The leaf index is not checked against the origin +// tree; the caller reads that from the MerkleTreeHook (`latestCheckpoint`) or +// from its `InsertedIntoTree` log. +func (v *Validator) SignMessage( + originMerkleTreeHook common.Address, + checkpoint Checkpoint, + message []byte, +) ([]byte, error) { + decoded := Message(message) + if err := decoded.Validate(); err != nil { + return nil, err + } + if origin := decoded.Origin(); origin != checkpoint.Domain { + return nil, fmt.Errorf( + "hyperlane: checkpoint domain %d does not match message origin %d", + checkpoint.Domain, origin, + ) + } + return v.SignCheckpoint(originMerkleTreeHook, checkpoint, MessageID(message)) +} diff --git a/go-runtime/internal/lab/anvil.go b/go-runtime/internal/lab/anvil.go new file mode 100644 index 0000000..fff7594 --- /dev/null +++ b/go-runtime/internal/lab/anvil.go @@ -0,0 +1,343 @@ +// Package lab starts disposable local EVM chains for runtime tests. +// +// The frozen XIR campaign runs on a five-chain Besu QBFT topology that needs +// more CPU and memory than a development host can spare. The Go runtime's +// integration tests therefore use anvil chains with the same chain IDs, the +// same one-second block period, and the same contracts deployed from the same +// Forge artifacts. This package is test infrastructure: it never produces +// campaign evidence and its chains are explicitly not the frozen lab. +package lab + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "time" +) + +// Spec describes one chain to start. +type Spec struct { + Role string + ChainID uint64 + BlockPeriod time.Duration + GasLimit uint64 + // Fund lists addresses that must hold a balance before the chain is used. + Fund []FundRequest +} + +// FundRequest is one balance assignment applied through anvil_setBalance. +type FundRequest struct { + Address string + Wei string +} + +// Chain is one running anvil process. +type Chain struct { + Role string + ChainID uint64 + URL string + + command *exec.Cmd + cancel context.CancelFunc + dir string + logPath string + wait sync.Once + done chan struct{} + err error + client *http.Client +} + +// Options configure chain startup. +type Options struct { + // AnvilPath overrides the anvil binary location. + AnvilPath string + // Dir is the parent directory for per-chain state; defaults to a temp dir. + Dir string + // LogWriter receives anvil output when set. + LogWriter io.Writer +} + +// AnvilPath resolves the anvil binary, returning "" when it is unavailable. +func AnvilPath(override string) string { + if override != "" { + return override + } + if path, err := exec.LookPath("anvil"); err == nil { + return path + } + for _, candidate := range []string{"/home/ubuntu/.foundry/bin/anvil", "/usr/local/bin/anvil"} { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + return "" +} + +// StartChains starts one anvil chain per spec on an allocated loopback port. +func StartChains(ctx context.Context, specs []Spec, options Options) ([]*Chain, error) { + binary := AnvilPath(options.AnvilPath) + if binary == "" { + return nil, fmt.Errorf("lab: anvil binary is unavailable") + } + root := options.Dir + if root == "" { + created, err := os.MkdirTemp("", "xir-lab-") + if err != nil { + return nil, fmt.Errorf("lab: create chain root: %w", err) + } + root = created + } + chains := make([]*Chain, 0, len(specs)) + for _, spec := range specs { + chain, err := startChain(ctx, binary, root, spec, options) + if err != nil { + for _, started := range chains { + started.Stop() + } + return nil, err + } + chains = append(chains, chain) + } + return chains, nil +} + +func startChain(ctx context.Context, binary, root string, spec Spec, options Options) (*Chain, error) { + if spec.Role == "" || spec.ChainID == 0 { + return nil, fmt.Errorf("lab: chain spec needs a role and chain id") + } + port, err := freeLoopbackPort() + if err != nil { + return nil, err + } + dir := filepath.Join(root, spec.Role) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("lab: create chain directory: %w", err) + } + logPath := filepath.Join(dir, "anvil.log") + logFile, err := os.Create(logPath) + if err != nil { + return nil, fmt.Errorf("lab: create chain log: %w", err) + } + defer func() { _ = logFile.Close() }() + + arguments := []string{ + "--host", "127.0.0.1", + "--port", strconv.Itoa(port), + "--chain-id", strconv.FormatUint(spec.ChainID, 10), + "--hardfork", "cancun", + } + if spec.BlockPeriod > 0 { + arguments = append(arguments, "--block-time", strconv.FormatInt(int64(spec.BlockPeriod/time.Second), 10)) + } + if spec.GasLimit > 0 { + arguments = append(arguments, "--gas-limit", strconv.FormatUint(spec.GasLimit, 10)) + } + processContext, cancel := context.WithCancel(context.WithoutCancel(ctx)) + command := exec.CommandContext(processContext, binary, arguments...) + command.Dir = dir + command.Stdout = logFile + command.Stderr = logFile + if err := command.Start(); err != nil { + cancel() + return nil, fmt.Errorf("lab: start anvil for %s: %w", spec.Role, err) + } + chain := &Chain{ + Role: spec.Role, + ChainID: spec.ChainID, + URL: "http://" + net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), + command: command, + cancel: cancel, + dir: dir, + logPath: logPath, + done: make(chan struct{}), + client: &http.Client{Timeout: 10 * time.Second}, + } + go func() { + chain.err = command.Wait() + close(chain.done) + }() + if err := chain.waitReady(ctx, 30*time.Second); err != nil { + chain.Stop() + return nil, err + } + for _, request := range spec.Fund { + if err := chain.SetBalance(ctx, request.Address, request.Wei); err != nil { + chain.Stop() + return nil, err + } + } + return chain, nil +} + +func freeLoopbackPort() (int, error) { + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", "0")) + if err != nil { + return 0, fmt.Errorf("lab: allocate loopback port: %w", err) + } + defer func() { _ = listener.Close() }() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func (c *Chain) waitReady(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + select { + case <-c.done: + return fmt.Errorf("lab: anvil for %s exited early: %s", c.Role, c.TailLogs()) + default: + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + var observed string + if err := c.call(ctx, "eth_chainId", nil, &observed); err == nil { + value, err := strconv.ParseUint(observed, 0, 64) + if err != nil { + return fmt.Errorf("lab: chain %s reported chain id %q", c.Role, observed) + } + if value != c.ChainID { + return fmt.Errorf("lab: chain %s reports chain id %d, want %d", c.Role, value, c.ChainID) + } + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("lab: anvil for %s did not become ready: %s", c.Role, c.TailLogs()) +} + +// SetBalance assigns a balance through anvil_setBalance. +func (c *Chain) SetBalance(ctx context.Context, address, wei string) error { + var ignored any + if err := c.call(ctx, "anvil_setBalance", []any{address, wei}, &ignored); err != nil { + return fmt.Errorf("lab: fund %s on %s: %w", address, c.Role, err) + } + return nil +} + +// Mine advances the chain through anvil_mine, which tests use to force a +// block without sending a transaction. +func (c *Chain) Mine(ctx context.Context, blocks uint64) error { + var ignored any + quantity := "0x" + strconv.FormatUint(blocks, 16) + if err := c.call(ctx, "anvil_mine", []any{quantity}, &ignored); err != nil { + return fmt.Errorf("lab: mine %d blocks on %s: %w", blocks, c.Role, err) + } + return nil +} + +// BalanceOf returns the balance of one address as a hexadecimal quantity. +func (c *Chain) BalanceOf(ctx context.Context, address string) (string, error) { + var observed string + if err := c.call(ctx, "eth_getBalance", []any{address, "latest"}, &observed); err != nil { + return "", err + } + return observed, nil +} + +// BlockNumber returns the current head of this chain. +func (c *Chain) BlockNumber(ctx context.Context) (uint64, error) { + var observed string + if err := c.call(ctx, "eth_blockNumber", nil, &observed); err != nil { + return 0, err + } + return strconv.ParseUint(observed, 0, 64) +} + +func (c *Chain) call(ctx context.Context, method string, params []any, into any) error { + if params == nil { + params = []any{} + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + if err != nil { + return fmt.Errorf("lab: encode %s request: %w", method, err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("lab: build %s request: %w", method, err) + } + request.Header.Set("Content-Type", "application/json") + response, err := c.client.Do(request) + if err != nil { + return fmt.Errorf("lab: %s call: %w", method, err) + } + defer func() { _ = response.Body.Close() }() + payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return fmt.Errorf("lab: read %s response: %w", method, err) + } + var envelope struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + return fmt.Errorf("lab: decode %s response: %w", method, err) + } + if envelope.Error != nil { + return fmt.Errorf("lab: %s failed: %s", method, envelope.Error.Message) + } + if into == nil || len(envelope.Result) == 0 { + return nil + } + if err := json.Unmarshal(envelope.Result, into); err != nil { + return fmt.Errorf("lab: decode %s result: %w", method, err) + } + return nil +} + +// TailLogs returns the last part of the chain log for diagnostics. +func (c *Chain) TailLogs() string { + payload, err := os.ReadFile(c.logPath) + if err != nil { + return "" + } + if len(payload) > 4096 { + payload = payload[len(payload)-4096:] + } + return string(payload) +} + +// Dir returns the per-chain working directory that holds the log. +func (c *Chain) Dir() string { return c.dir } + +// Stop terminates the chain and waits for the process to exit. +func (c *Chain) Stop() { + if c.cancel != nil { + c.cancel() + } + c.wait.Do(func() { + select { + case <-c.done: + case <-time.After(5 * time.Second): + if c.command != nil && c.command.Process != nil { + _ = c.command.Process.Kill() + } + <-c.done + } + }) +} + +// StopChains terminates every chain. +func StopChains(chains []*Chain) { + for _, chain := range chains { + chain.Stop() + } +} diff --git a/go-runtime/internal/lab/anvil_test.go b/go-runtime/internal/lab/anvil_test.go new file mode 100644 index 0000000..9dfeba3 --- /dev/null +++ b/go-runtime/internal/lab/anvil_test.go @@ -0,0 +1,75 @@ +package lab + +import ( + "context" + "math/big" + "strings" + "testing" + "time" +) + +func TestStartChainsServesDistinctChainsAndFunding(t *testing.T) { + if AnvilPath("") == "" { + t.Skip("anvil binary is unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + const funded = "0x00000000000000000000000000000000000000aa" + chains, err := StartChains(ctx, []Spec{ + {Role: "a", ChainID: 3133701, BlockPeriod: time.Second, Fund: []FundRequest{{Address: funded, Wei: "0xde0b6b3a7640000"}}}, + {Role: "b", ChainID: 3133702, BlockPeriod: time.Second}, + }, Options{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("start chains: %v", err) + } + defer StopChains(chains) + if len(chains) != 2 { + t.Fatalf("expected two chains, got %d", len(chains)) + } + if chains[0].URL == chains[1].URL { + t.Fatalf("chains share a URL: %s", chains[0].URL) + } + balance, err := chains[0].BalanceOf(ctx, funded) + if err != nil { + t.Fatalf("read funded balance: %v", err) + } + observed, ok := new(big.Int).SetString(strings.TrimPrefix(balance, "0x"), 16) + if !ok { + t.Fatalf("balance %q is not a hexadecimal quantity", balance) + } + if observed.Cmp(big.NewInt(0)) <= 0 { + t.Fatalf("funded address balance is %s", balance) + } + if empty, err := chains[1].BalanceOf(ctx, funded); err != nil { + t.Fatalf("read unfunded balance: %v", err) + } else if empty != "0x0" { + t.Fatalf("second chain funded an address it should not have: %s", empty) + } + if err := chains[0].Mine(ctx, 1); err != nil { + t.Fatalf("mine: %v", err) + } + height, err := chains[0].BlockNumber(ctx) + if err != nil { + t.Fatalf("read block number: %v", err) + } + if height == 0 { + t.Fatalf("block number did not advance") + } +} + +func TestStopChainsTerminatesProcesses(t *testing.T) { + if AnvilPath("") == "" { + t.Skip("anvil binary is unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + chains, err := StartChains(ctx, []Spec{{Role: "a", ChainID: 3133701}}, Options{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("start chains: %v", err) + } + chain := chains[0] + StopChains(chains) + if _, err := chain.BlockNumber(ctx); err == nil { + t.Fatalf("stopped chain still answers requests") + } +} diff --git a/go-runtime/internal/layerzero/abi.go b/go-runtime/internal/layerzero/abi.go new file mode 100644 index 0000000..ebbd949 --- /dev/null +++ b/go-runtime/internal/layerzero/abi.go @@ -0,0 +1,226 @@ +package layerzero + +import ( + "bytes" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +// The ABI fragments below are the members of the official LayerZero V2 +// contracts that this runtime encodes or decodes. They are copied verbatim from +// protocol-projects/layerzero-native/out/.sol/.json, because +// abiutil packs tuple arguments from ABI components rather than from type +// strings. +const dvnABIDocument = `[ + { + "type": "function", + "name": "execute", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "_params", + "type": "tuple[]", + "internalType": "struct ExecuteParam[]", + "components": [ + {"name": "vid", "type": "uint32", "internalType": "uint32"}, + {"name": "target", "type": "address", "internalType": "address"}, + {"name": "callData", "type": "bytes", "internalType": "bytes"}, + {"name": "expiration", "type": "uint256", "internalType": "uint256"}, + {"name": "signatures", "type": "bytes", "internalType": "bytes"} + ] + } + ], + "outputs": [] + } +]` + +const receiveULNABIDocument = `[ + { + "type": "function", + "name": "verify", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "_packetHeader", "type": "bytes", "internalType": "bytes"}, + {"name": "_payloadHash", "type": "bytes32", "internalType": "bytes32"}, + {"name": "_confirmations", "type": "uint64", "internalType": "uint64"} + ], + "outputs": [] + }, + { + "type": "function", + "name": "commitVerification", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "_packetHeader", "type": "bytes", "internalType": "bytes"}, + {"name": "_payloadHash", "type": "bytes32", "internalType": "bytes32"} + ], + "outputs": [] + }, + { + "type": "event", + "name": "PayloadVerified", + "anonymous": false, + "inputs": [ + {"name": "dvn", "type": "address", "indexed": false, "internalType": "address"}, + {"name": "header", "type": "bytes", "indexed": false, "internalType": "bytes"}, + {"name": "confirmations", "type": "uint256", "indexed": false, "internalType": "uint256"}, + {"name": "proofHash", "type": "bytes32", "indexed": false, "internalType": "bytes32"} + ] + } +]` + +const executorABIDocument = `[ + { + "type": "function", + "name": "execute302", + "stateMutability": "payable", + "inputs": [ + { + "name": "_executionParams", + "type": "tuple", + "internalType": "struct IExecutor.ExecutionParams", + "components": [ + {"name": "receiver", "type": "address", "internalType": "address"}, + { + "name": "origin", + "type": "tuple", + "internalType": "struct Origin", + "components": [ + {"name": "srcEid", "type": "uint32", "internalType": "uint32"}, + {"name": "sender", "type": "bytes32", "internalType": "bytes32"}, + {"name": "nonce", "type": "uint64", "internalType": "uint64"} + ] + }, + {"name": "guid", "type": "bytes32", "internalType": "bytes32"}, + {"name": "message", "type": "bytes", "internalType": "bytes"}, + {"name": "extraData", "type": "bytes", "internalType": "bytes"}, + {"name": "gasLimit", "type": "uint256", "internalType": "uint256"} + ] + } + ], + "outputs": [] + } +]` + +const endpointV2ABIDocument = `[ + { + "type": "event", + "name": "PacketSent", + "anonymous": false, + "inputs": [ + {"name": "encodedPayload", "type": "bytes", "indexed": false, "internalType": "bytes"}, + {"name": "options", "type": "bytes", "indexed": false, "internalType": "bytes"}, + {"name": "sendLibrary", "type": "address", "indexed": false, "internalType": "address"} + ] + }, + { + "type": "event", + "name": "PacketVerified", + "anonymous": false, + "inputs": [ + { + "name": "origin", + "type": "tuple", + "indexed": false, + "internalType": "struct Origin", + "components": [ + {"name": "srcEid", "type": "uint32", "indexed": false, "internalType": "uint32"}, + {"name": "sender", "type": "bytes32", "indexed": false, "internalType": "bytes32"}, + {"name": "nonce", "type": "uint64", "indexed": false, "internalType": "uint64"} + ] + }, + {"name": "receiver", "type": "address", "indexed": false, "internalType": "address"}, + {"name": "payloadHash", "type": "bytes32", "indexed": false, "internalType": "bytes32"} + ] + }, + { + "type": "event", + "name": "PacketDelivered", + "anonymous": false, + "inputs": [ + { + "name": "origin", + "type": "tuple", + "indexed": false, + "internalType": "struct Origin", + "components": [ + {"name": "srcEid", "type": "uint32", "indexed": false, "internalType": "uint32"}, + {"name": "sender", "type": "bytes32", "indexed": false, "internalType": "bytes32"}, + {"name": "nonce", "type": "uint64", "indexed": false, "internalType": "uint64"} + ] + }, + {"name": "receiver", "type": "address", "indexed": false, "internalType": "address"} + ] + } +]` + +// The parsed fragments. The documents are compile-time constants, so a parse +// failure would be a defect in this package; the tests parse them first. +var ( + dvnContract = mustContract("DVN", dvnABIDocument) + receiveULNContract = mustContract("ReceiveUln302", receiveULNABIDocument) + executorContract = mustContract("Executor", executorABIDocument) + endpointV2Contract = mustContract("EndpointV2", endpointV2ABIDocument) +) + +func mustContract(name, document string) *abiutil.Contract { + contract, err := abiutil.New(document) + if err != nil { + panic(err) + } + contract.SetName(name) + return contract +} + +// dvnExecuteParam is the ABI shape of official DVN.ExecuteParam. +type dvnExecuteParam struct { + VID uint32 `abi:"vid"` + Target common.Address `abi:"target"` + CallData []byte `abi:"callData"` + Expiration *big.Int `abi:"expiration"` + Signatures []byte `abi:"signatures"` +} + +// originParam is the ABI shape of the official Origin struct. +type originParam struct { + SourceEID uint32 `abi:"srcEid"` + Sender [32]byte `abi:"sender"` + Nonce uint64 `abi:"nonce"` +} + +// executionParams is the ABI shape of IExecutor.ExecutionParams. +type executionParams struct { + Receiver common.Address `abi:"receiver"` + Origin originParam `abi:"origin"` + GUID [32]byte `abi:"guid"` + Message []byte `abi:"message"` + ExtraData []byte `abi:"extraData"` + GasLimit *big.Int `abi:"gasLimit"` +} + +// DVNExpiration recovers the deadline from a single frozen execute instruction. +// Rebuilding with that deadline preserves both the signature and calldata; the +// transactor still checks the complete plan against the frozen identity. +func DVNExpiration(data []byte) (*big.Int, error) { + method := dvnContract.ABI().Methods["execute"] + if len(data) < 4 || !bytes.Equal(data[:4], method.ID) { + return nil, fmt.Errorf("not DVN.execute calldata") + } + values, err := method.Inputs.Unpack(data[4:]) + if err != nil { + return nil, err + } + if len(values) != 1 { + return nil, fmt.Errorf("expected execute parameters") + } + params := *abi.ConvertType(values[0], new([]dvnExecuteParam)).(*[]dvnExecuteParam) + if len(params) != 1 || params[0].Expiration == nil { + return nil, fmt.Errorf("expected one DVN instruction") + } + return new(big.Int).Set(params[0].Expiration), nil +} diff --git a/go-runtime/internal/layerzero/dvn.go b/go-runtime/internal/layerzero/dvn.go new file mode 100644 index 0000000..fed25a9 --- /dev/null +++ b/go-runtime/internal/layerzero/dvn.go @@ -0,0 +1,150 @@ +package layerzero + +import ( + "encoding/binary" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// DigestSigner signs a 32-byte digest with the EIP-191 personal signing rule +// that eth_account.encode_defunct implements. internal/evm.Signer satisfies it +// through SignPersonalDigest, which frames the digest as +// "\x19Ethereum Signed Message:\n32" || digest. +type DigestSigner interface { + SignPersonalDigest(digest [32]byte) ([]byte, error) +} + +// DVNInstruction is one signed official DVN verification instruction. +type DVNInstruction struct { + // VID is the destination endpoint id the instruction verifies for. + VID uint32 + // Target is the destination ReceiveUln302 the instruction calls. + Target common.Address + // CallData is verify(header, payloadHash, confirmations) on the target. + CallData []byte + // Expiration is the unix second after which the DVN refuses the instruction. + Expiration *big.Int + // Signature is the personal signature over Hash. + Signature []byte + // Hash is keccak(vid || target || expiration || callData). + Hash [32]byte +} + +// DVNRequest describes one verification instruction to build and sign. +type DVNRequest struct { + // VID is the destination endpoint id, which must be non-zero. + VID uint32 + // ReceiveULN is the destination ReceiveUln302, which must be non-zero. + ReceiveULN common.Address + // Packet is the decoded source packet to verify. + Packet Packet + // Confirmations is the source confirmation count the instruction attests. + Confirmations uint64 + // Expiration is the unix second the instruction stays valid until. + Expiration *big.Int + // Signer signs the packed instruction hash. + Signer DigestSigner +} + +// BuildDVNInstruction builds and signs the official DVN.execute verification +// instruction: the target call is verify(bytes,bytes32,uint64), the signed hash +// is keccak(vid || target || expiration || callData) with vid as a 4-byte and +// expiration as a 32-byte big-endian integer, and the signature is the +// EIP-191 personal signature over that hash. +func BuildDVNInstruction(request DVNRequest) (DVNInstruction, error) { + if request.VID == 0 { + return DVNInstruction{}, errors.New("layerzero: DVN vid is out of range") + } + if !fitsUint256(request.Expiration) || request.Expiration.Sign() <= 0 { + return DVNInstruction{}, fmt.Errorf("layerzero: DVN expiration %v is invalid", request.Expiration) + } + if request.ReceiveULN == (common.Address{}) { + return DVNInstruction{}, errors.New("layerzero: receive ULN address is invalid") + } + if len(request.Packet.Header) != PacketHeaderBytes { + return DVNInstruction{}, fmt.Errorf("layerzero: packet header is %d bytes, want %d", len(request.Packet.Header), PacketHeaderBytes) + } + if request.Signer == nil { + return DVNInstruction{}, errors.New("layerzero: DVN instruction requires a signer") + } + callData, err := receiveULNContract.PackCall("verify", request.Packet.Header, request.Packet.PayloadHash, request.Confirmations) + if err != nil { + return DVNInstruction{}, err + } + packed := make([]byte, 0, 4+common.AddressLength+32+len(callData)) + packed = binary.BigEndian.AppendUint32(packed, request.VID) + packed = append(packed, request.ReceiveULN[:]...) + var expiration [32]byte + request.Expiration.FillBytes(expiration[:]) + packed = append(packed, expiration[:]...) + packed = append(packed, callData...) + hash := xir.Keccak256(packed) + signature, err := request.Signer.SignPersonalDigest(hash) + if err != nil { + return DVNInstruction{}, fmt.Errorf("layerzero: sign DVN instruction: %w", err) + } + return DVNInstruction{ + VID: request.VID, + Target: request.ReceiveULN, + CallData: callData, + Expiration: new(big.Int).Set(request.Expiration), + Signature: signature, + Hash: hash, + }, nil +} + +// EncodeDVNExecute encodes DVN.execute((uint32,address,bytes,uint256,bytes)[]) +// with exactly one instruction. +func EncodeDVNExecute(instruction DVNInstruction) ([]byte, error) { + if !fitsUint256(instruction.Expiration) { + return nil, fmt.Errorf("layerzero: DVN expiration %v is invalid", instruction.Expiration) + } + params := []dvnExecuteParam{{ + VID: instruction.VID, + Target: instruction.Target, + CallData: instruction.CallData, + Expiration: instruction.Expiration, + Signatures: instruction.Signature, + }} + return dvnContract.PackCall("execute", params) +} + +// EncodeCommitVerification encodes +// ReceiveUln302.commitVerification(bytes,bytes32) for the packet header and its +// payload hash. +func EncodeCommitVerification(packet Packet) ([]byte, error) { + return receiveULNContract.PackCall("commitVerification", packet.Header, packet.PayloadHash) +} + +// EncodeExecutorSubmission encodes Executor.execute302 with the packet receiver, +// the packet origin, the GUID, the message, empty extra data, and the executor +// gas limit. The receiver must be an EVM address. +func EncodeExecutorSubmission(packet Packet, gasLimit uint64) ([]byte, error) { + receiver, err := packet.ReceiverAddress() + if err != nil { + return nil, err + } + params := executionParams{ + Receiver: receiver, + Origin: originParam{ + SourceEID: packet.SourceEID, + Sender: packet.Sender, + Nonce: packet.Nonce, + }, + GUID: packet.GUID, + Message: packet.Message, + ExtraData: []byte{}, + GasLimit: new(big.Int).SetUint64(gasLimit), + } + return executorContract.PackCall("execute302", params) +} + +// fitsUint256 reports whether the value can be encoded as an ABI uint256. +func fitsUint256(value *big.Int) bool { + return value != nil && value.Sign() >= 0 && value.BitLen() <= maxUint256Bits +} diff --git a/go-runtime/internal/layerzero/layerzero.go b/go-runtime/internal/layerzero/layerzero.go new file mode 100644 index 0000000..2ad31f3 --- /dev/null +++ b/go-runtime/internal/layerzero/layerzero.go @@ -0,0 +1,148 @@ +// Package layerzero implements the LayerZero V2 wire format and the DVN, +// ReceiveUln302, and Executor submissions the XIR runtime performs. +// +// It is a port of src/xir_lab/native/layerzero.py and of the submission +// semantics of src/xir_lab/native/layerzero_worker.py. Durability and +// broadcasting stay outside this package: Plan returns the three ordered +// actions the worker submits, ExtractPacket performs the PacketSent decode the +// worker's collection loop performs, and Action.ExpectedTopic records the +// official event each stage receipt must carry. +package layerzero + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// PacketV1Codec layout constants (official LayerZero V2 offsets). +const ( + // PacketHeaderBytes is the length of the packed packet header. + PacketHeaderBytes = 81 + // PacketGUIDOffset is the offset of the 32-byte GUID in an encoded packet. + PacketGUIDOffset = 81 + // PacketMessageOffset is the offset of the message in an encoded packet. + PacketMessageOffset = 113 + // PacketVersion is the only packet version this codec accepts. + PacketVersion = 1 + // maxUint256Bits bounds every uint256 the package encodes. + maxUint256Bits = 256 +) + +// Stage names of the LayerZero worker sequence, equal to +// layerzero_worker.STAGES. The runner prefixes them with the hop it is +// delivering ("hop_1_layerzero_dvn_execute"). +const ( + // StageDVNExecute calls DVN.execute, which verifies the packet payload. + StageDVNExecute = "dvn_execute" + // StageCommitVerification calls ReceiveUln302.commitVerification. + StageCommitVerification = "commit_verification" + // StageExecutorExecute calls Executor.execute302, which delivers the message. + StageExecutorExecute = "executor_execute" +) + +// Stages is the submission order of one LayerZero delivery. +var Stages = [3]string{StageDVNExecute, StageCommitVerification, StageExecutorExecute} + +// Canonical signatures of the official V2 events the worker watches. +const ( + PacketSentSignature = "PacketSent(bytes,bytes,address)" + PayloadVerifiedSignature = "PayloadVerified(address,bytes,uint256,bytes32)" + PacketVerifiedSignature = "PacketVerified((uint32,bytes32,uint64),address,bytes32)" + PacketDeliveredSignature = "PacketDelivered((uint32,bytes32,uint64),address)" +) + +// Topic0 of the official V2 events: keccak of the canonical signature. +// TestEventTopicsMatchOfficialABI re-derives each value from PacketSentSignature +// and friends and from the ABI fragments in abi.go. +const ( + PacketSentTopic = "0x1ab700d4ced0c005b164c0f789fd09fcbb0156d4c2041b8a3bfbcd961cd1567f" + PayloadVerifiedTopic = "0x2cb0eed7538baeae4c6fde038c0fd0384d27de0dd55a228c65847bda6aa1ab56" + PacketVerifiedTopic = "0x0d87345f3d1c929caba93e1c3821b54ff3512e12b66aa3cfe54b6bcbc17e59b4" + PacketDeliveredTopic = "0x3cd5e48f9730b129dc7550f0fcea9c767b7be37837cd10e55eb35f734f4bca04" +) + +// Packet is one decoded PacketV1Codec payload. +// +// Encoded, Header, and Message alias the buffer handed to DecodePacket; the +// decoder copies nothing. +type Packet struct { + Encoded []byte + Header []byte + Version uint8 + Nonce uint64 + SourceEID uint32 + Sender [32]byte + DestinationEID uint32 + Receiver [32]byte + GUID [32]byte + Message []byte + PayloadHash [32]byte +} + +// DecodePacket decodes the official PacketV1Codec packed wire format: +// version || nonce || srcEid || sender || dstEid || receiver || guid || message, +// with payload_hash = keccak(guid || message). +// +// A packet shorter than the message offset, a packet whose version is not V1, +// and a packet whose receiver is not a left-padded EVM address are rejected. +func DecodePacket(encoded []byte) (Packet, error) { + if len(encoded) < PacketMessageOffset { + return Packet{}, fmt.Errorf("layerzero: packet is %d bytes, shorter than the PacketV1Codec header of %d", len(encoded), PacketMessageOffset) + } + if encoded[0] != PacketVersion { + return Packet{}, fmt.Errorf("layerzero: packet version %d is not V1", encoded[0]) + } + packet := Packet{ + Encoded: encoded, + Header: encoded[:PacketHeaderBytes], + Version: encoded[0], + Nonce: binary.BigEndian.Uint64(encoded[1:9]), + SourceEID: binary.BigEndian.Uint32(encoded[9:13]), + Sender: [32]byte(encoded[13:45]), + DestinationEID: binary.BigEndian.Uint32(encoded[45:49]), + Receiver: [32]byte(encoded[49:81]), + GUID: [32]byte(encoded[PacketGUIDOffset:PacketMessageOffset]), + Message: encoded[PacketMessageOffset:], + PayloadHash: xir.Keccak256(encoded[PacketGUIDOffset:]), + } + if _, err := packet.ReceiverAddress(); err != nil { + return Packet{}, err + } + return packet, nil +} + +// ReceiverAddress returns the 20-byte EVM receiver carried in the packet. +// A receiver whose leading twelve bytes are not zero is not an EVM address. +func (p Packet) ReceiverAddress() (common.Address, error) { + var reserved [12]byte + if !bytes.Equal(p.Receiver[:len(reserved)], reserved[:]) { + return common.Address{}, errors.New("layerzero: packet receiver is not an EVM bytes32 address") + } + return common.BytesToAddress(p.Receiver[len(reserved):]), nil +} + +// ReceiveOptions builds the official Type-3 Executor LZ_RECEIVE options for one +// gas limit with zero native value: type 3, worker id 1, option type 17, the +// option marker 1, then the limit as a 16-byte big-endian uint128. +// +// The official encoding carries the limit as a uint128, so a uint64 caller can +// never exceed that ceiling; the limit must still be positive. +func ReceiveOptions(gasLimit uint64) ([]byte, error) { + if gasLimit == 0 { + return nil, errors.New("layerzero: executor gas limit is out of uint128 range") + } + options := make([]byte, 0, 6+16) + options = append(options, 0x00, 0x03, 0x01, 0x00, 0x11, 0x01) + var encoded [16]byte + binary.BigEndian.PutUint64(encoded[8:], gasLimit) + return append(options, encoded[:]...), nil +} + +// topicHash parses one topic constant into the hash form log topics carry. +func topicHash(topic string) common.Hash { return common.HexToHash(topic) } diff --git a/go-runtime/internal/layerzero/layerzero_test.go b/go-runtime/internal/layerzero/layerzero_test.go new file mode 100644 index 0000000..a972485 --- /dev/null +++ b/go-runtime/internal/layerzero/layerzero_test.go @@ -0,0 +1,436 @@ +package layerzero + +import ( + "bytes" + "crypto/ecdsa" + "encoding/binary" + "encoding/hex" + "encoding/json" + "math" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// The vector generator pins these three values for every packet vector +// (scripts/generate_go_parity_vectors.py::_packet_vectors). +const ( + vectorConfirmations = 1 + vectorExpiration = 1_900_000_000 + vectorGasLimit = 1_500_000 +) + +type layerZeroVector struct { + Nonce uint64 `json:"nonce"` + EncodedPacket string `json:"encoded_packet"` + SourceEID uint32 `json:"source_eid"` + DestinationEID uint32 `json:"destination_eid"` + Sender string `json:"sender"` + Receiver string `json:"receiver"` + GUID string `json:"guid"` + Message string `json:"message"` + PayloadHash string `json:"payload_hash"` + DVNInstruction struct { + VID uint32 `json:"vid"` + Target string `json:"target"` + CallData string `json:"call_data"` + Expiration int64 `json:"expiration"` + InstructionHash string `json:"instruction_hash"` + Signature string `json:"signature"` + } `json:"dvn_instruction"` + DVNExecuteCalldata string `json:"dvn_execute_calldata"` + CommitVerificationCalldata string `json:"commit_verification_calldata"` + ExecutorCalldata string `json:"executor_calldata"` + ExecutorOptions string `json:"executor_options"` +} + +type vectorDocument struct { + Constants struct { + FixtureKeys map[string]string `json:"fixture_keys"` + } `json:"constants"` + LayerZeroPackets []layerZeroVector `json:"layerzero_packets"` +} + +func loadVectors(t *testing.T) vectorDocument { + t.Helper() + payload, err := os.ReadFile(filepath.Join("..", "..", "testdata", "vectors.json")) + if err != nil { + t.Fatalf("read parity vectors: %v", err) + } + var document vectorDocument + if err := json.Unmarshal(payload, &document); err != nil { + t.Fatalf("parse parity vectors: %v", err) + } + if len(document.LayerZeroPackets) == 0 { + t.Fatal("parity vectors carry no layerzero_packets") + } + return document +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + raw, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + t.Fatalf("decode %q: %v", value, err) + } + return raw +} + +func hexOf(value []byte) string { return "0x" + hex.EncodeToString(value) } + +func checkHex(t *testing.T, name string, value []byte, want string) { + t.Helper() + if got := hexOf(value); got != want { + t.Errorf("%s = %s, want %s", name, got, want) + } +} + +// checkKeccak compares the keccak256 of parts with the expected hex value. +func checkKeccak(t *testing.T, name string, want string, parts ...[]byte) { + t.Helper() + digest := xir.Keccak256(parts...) + if got := hexOf(digest[:]); got != want { + t.Errorf("%s = %s, want %s", name, got, want) + } +} + +// selector derives the 4-byte selector of a canonical ABI signature. +func selector(signature string) []byte { + digest := xir.Keccak256([]byte(signature)) + return digest[:4] +} + +// testSigner signs digests exactly as eth_account's encode_defunct does, which +// is what internal/evm.Signer.SignPersonalDigest implements. +type testSigner struct { + key *ecdsa.PrivateKey +} + +func newTestSigner(t *testing.T, privateKey string) testSigner { + t.Helper() + if privateKey == "" { + t.Fatal("parity vectors carry no dvn_signer fixture key") + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(privateKey, "0x")) + if err != nil { + t.Fatalf("parse fixture key: %v", err) + } + return testSigner{key: key} +} + +func (s testSigner) SignPersonalDigest(digest [32]byte) ([]byte, error) { + signature, err := crypto.Sign(personalDigest(digest), s.key) + if err != nil { + return nil, err + } + signature[64] += 27 + return signature, nil +} + +func (s testSigner) address() common.Address { return crypto.PubkeyToAddress(s.key.PublicKey) } + +// personalDigest frames a digest the way EIP-191 personal signing does. +func personalDigest(digest [32]byte) []byte { + return crypto.Keccak256(append([]byte("\x19Ethereum Signed Message:\n32"), digest[:]...)) +} + +func recoverAddress(t *testing.T, hash, signature []byte) common.Address { + t.Helper() + if len(signature) != 65 { + t.Fatalf("signature is %d bytes, want 65", len(signature)) + } + normalized := append([]byte(nil), signature...) + normalized[64] -= 27 + publicKey, err := crypto.Ecrecover(hash, normalized) + if err != nil { + t.Fatalf("recover signer: %v", err) + } + return common.BytesToAddress(crypto.Keccak256(publicKey[1:])[12:]) +} + +// TestLayerZeroVectorsMatchPythonReference compares every field the generator +// writes for layerzero_packets against this package. +func TestLayerZeroVectorsMatchPythonReference(t *testing.T) { + document := loadVectors(t) + signer := newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"]) + for _, vector := range document.LayerZeroPackets { + t.Run(strconv.FormatUint(vector.Nonce, 10), func(t *testing.T) { + encoded := mustHex(t, vector.EncodedPacket) + packet, err := DecodePacket(encoded) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + if packet.Version != PacketVersion { + t.Errorf("version = %d, want %d", packet.Version, PacketVersion) + } + if packet.Nonce != vector.Nonce { + t.Errorf("nonce = %d, want %d", packet.Nonce, vector.Nonce) + } + if packet.SourceEID != vector.SourceEID { + t.Errorf("source_eid = %d, want %d", packet.SourceEID, vector.SourceEID) + } + if packet.DestinationEID != vector.DestinationEID { + t.Errorf("destination_eid = %d, want %d", packet.DestinationEID, vector.DestinationEID) + } + checkHex(t, "encoded", packet.Encoded, vector.EncodedPacket) + checkHex(t, "header", packet.Header, vector.EncodedPacket[:2+2*PacketHeaderBytes]) + checkHex(t, "sender", packet.Sender[:], vector.Sender) + checkHex(t, "receiver", packet.Receiver[:], vector.Receiver) + checkHex(t, "guid", packet.GUID[:], vector.GUID) + checkHex(t, "message", packet.Message, vector.Message) + checkHex(t, "payload_hash", packet.PayloadHash[:], vector.PayloadHash) + checkKeccak(t, "keccak(guid||message)", vector.PayloadHash, packet.GUID[:], packet.Message) + + receiver, err := packet.ReceiverAddress() + if err != nil { + t.Fatalf("ReceiverAddress: %v", err) + } + checkHex(t, "receiver_address", receiver[:], "0x"+strings.TrimPrefix(vector.Receiver, "0x")[24:]) + + instruction, err := BuildDVNInstruction(DVNRequest{ + VID: vector.DestinationEID, + ReceiveULN: common.HexToAddress(vector.DVNInstruction.Target), + Packet: packet, + Confirmations: vectorConfirmations, + Expiration: big.NewInt(vectorExpiration), + Signer: signer, + }) + if err != nil { + t.Fatalf("BuildDVNInstruction: %v", err) + } + if instruction.VID != vector.DVNInstruction.VID { + t.Errorf("dvn vid = %d, want %d", instruction.VID, vector.DVNInstruction.VID) + } + checkHex(t, "dvn target", instruction.Target[:], vector.DVNInstruction.Target) + checkHex(t, "dvn call_data", instruction.CallData, vector.DVNInstruction.CallData) + checkHex(t, "dvn instruction_hash", instruction.Hash[:], vector.DVNInstruction.InstructionHash) + checkHex(t, "dvn signature", instruction.Signature, vector.DVNInstruction.Signature) + if instruction.Expiration.Cmp(big.NewInt(vector.DVNInstruction.Expiration)) != 0 { + t.Errorf("dvn expiration = %s, want %d", instruction.Expiration, vector.DVNInstruction.Expiration) + } + var expiration [32]byte + big.NewInt(vector.DVNInstruction.Expiration).FillBytes(expiration[:]) + packed := binary.BigEndian.AppendUint32(nil, vector.DVNInstruction.VID) + packed = append(packed, instruction.Target[:]...) + packed = append(packed, expiration[:]...) + packed = append(packed, instruction.CallData...) + checkKeccak(t, "keccak(vid||target||expiration||call_data)", vector.DVNInstruction.InstructionHash, packed) + + dvnExecute, err := EncodeDVNExecute(instruction) + if err != nil { + t.Fatalf("EncodeDVNExecute: %v", err) + } + checkHex(t, "dvn_execute_calldata", dvnExecute, vector.DVNExecuteCalldata) + + commitVerification, err := EncodeCommitVerification(packet) + if err != nil { + t.Fatalf("EncodeCommitVerification: %v", err) + } + checkHex(t, "commit_verification_calldata", commitVerification, vector.CommitVerificationCalldata) + + executor, err := EncodeExecutorSubmission(packet, vectorGasLimit) + if err != nil { + t.Fatalf("EncodeExecutorSubmission: %v", err) + } + checkHex(t, "executor_calldata", executor, vector.ExecutorCalldata) + + options, err := ReceiveOptions(vectorGasLimit) + if err != nil { + t.Fatalf("ReceiveOptions: %v", err) + } + checkHex(t, "executor_options", options, vector.ExecutorOptions) + }) + } +} + +// TestSelectorsMatchCanonicalSignatures derives every selector this package +// prepends from its canonical signature. +func TestSelectorsMatchCanonicalSignatures(t *testing.T) { + for _, test := range []struct { + signature string + selector string + }{ + {"verify(bytes,bytes32,uint64)", "0x0223536e"}, + {"execute((uint32,address,bytes,uint256,bytes)[])", "0xb143044b"}, + {"commitVerification(bytes,bytes32)", "0x0894edf1"}, + {"execute302((address,(uint32,bytes32,uint64),bytes32,bytes,bytes,uint256))", "0xcfc32570"}, + } { + checkHex(t, test.signature, selector(test.signature), test.selector) + } +} + +// TestEventTopicsMatchOfficialABI re-derives the four topic constants from +// their canonical signatures and from the committed ABI fragments. +func TestEventTopicsMatchOfficialABI(t *testing.T) { + for _, test := range []struct { + name string + signature string + topic string + hash common.Hash + }{ + {"PacketSent", PacketSentSignature, PacketSentTopic, eventTopic(t, endpointV2Contract, "PacketSent")}, + {"PayloadVerified", PayloadVerifiedSignature, PayloadVerifiedTopic, eventTopic(t, receiveULNContract, "PayloadVerified")}, + {"PacketVerified", PacketVerifiedSignature, PacketVerifiedTopic, eventTopic(t, endpointV2Contract, "PacketVerified")}, + {"PacketDelivered", PacketDeliveredSignature, PacketDeliveredTopic, eventTopic(t, endpointV2Contract, "PacketDelivered")}, + } { + checkKeccak(t, test.name+" topic", test.topic, []byte(test.signature)) + if topicHash(test.topic) != test.hash { + t.Errorf("%s topic = %s, want ABI topic %s", test.name, test.topic, test.hash) + } + } +} + +// TestDVNSignatureUsesPersonalSigningDomain pins the signing domain: the Python +// reference signs the instruction hash with eth_account's encode_defunct, so the +// fixture signature recovers the DVN key only under the EIP-191 framing. +func TestDVNSignatureUsesPersonalSigningDomain(t *testing.T) { + document := loadVectors(t) + signer := newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"]) + instruction := document.LayerZeroPackets[0].DVNInstruction + var digest [32]byte + copy(digest[:], mustHex(t, instruction.InstructionHash)) + signature := mustHex(t, instruction.Signature) + + if got := recoverAddress(t, personalDigest(digest), signature); got != signer.address() { + t.Errorf("personal digest recovers %s, want the DVN signer %s", got, signer.address()) + } + if got := recoverAddress(t, digest[:], signature); got == signer.address() { + t.Error("raw digest recovers the DVN signer: the vector is not EIP-191 framed") + } + if !bytes.Equal(signature[64:], []byte{0x1b}) && !bytes.Equal(signature[64:], []byte{0x1c}) { + t.Errorf("signature recovery byte = %d, want the personal-signing range 27..28", signature[64]) + } +} + +func TestDecodePacketRejectsMalformedPackets(t *testing.T) { + document := loadVectors(t) + encoded := mustHex(t, document.LayerZeroPackets[0].EncodedPacket) + for _, test := range []struct { + name string + mutate func([]byte) []byte + }{ + {"empty", func([]byte) []byte { return []byte{} }}, + {"truncated", func(value []byte) []byte { return value[:PacketMessageOffset-1] }}, + {"header only", func(value []byte) []byte { return value[:PacketHeaderBytes] }}, + {"wrong version", func(value []byte) []byte { mutated := bytes.Clone(value); mutated[0] = 2; return mutated }}, + {"non-EVM receiver", func(value []byte) []byte { mutated := bytes.Clone(value); mutated[49] = 1; return mutated }}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := DecodePacket(test.mutate(encoded)); err == nil { + t.Fatal("DecodePacket accepted a malformed packet") + } + }) + } +} + +// TestDecodePacketAcceptsEmptyMessage checks the shortest legal packet: a full +// header and GUID with no message bytes. +func TestDecodePacketAcceptsEmptyMessage(t *testing.T) { + encoded := make([]byte, PacketMessageOffset) + encoded[0] = PacketVersion + packet, err := DecodePacket(encoded) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + if len(packet.Message) != 0 { + t.Errorf("message = %s, want empty", hexOf(packet.Message)) + } + if want := xir.Keccak256(encoded[PacketGUIDOffset:]); packet.PayloadHash != want { + t.Errorf("payload_hash = %s, want %s", hexOf(packet.PayloadHash[:]), hexOf(want[:])) + } +} + +// TestReceiveOptionsRejectsOutOfRangeGasLimit covers the official uint128 rule. +// A uint64 caller cannot reach the upper bound, so the lower bound is the one +// bound this type can violate. +func TestReceiveOptionsRejectsOutOfRangeGasLimit(t *testing.T) { + if _, err := ReceiveOptions(0); err == nil { + t.Fatal("ReceiveOptions accepted a zero gas limit") + } + options, err := ReceiveOptions(math.MaxUint64) + if err != nil { + t.Fatalf("ReceiveOptions: %v", err) + } + checkHex(t, "max gas options", options, "0x000301001101"+"0000000000000000ffffffffffffffff") +} + +func TestBuildDVNInstructionRejectsInvalidRequests(t *testing.T) { + document := loadVectors(t) + packet, err := DecodePacket(mustHex(t, document.LayerZeroPackets[0].EncodedPacket)) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + signer := newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"]) + base := func() DVNRequest { + return DVNRequest{ + VID: packet.DestinationEID, + ReceiveULN: common.HexToAddress("0x" + strings.Repeat("cc", 20)), + Packet: packet, + Confirmations: vectorConfirmations, + Expiration: big.NewInt(vectorExpiration), + Signer: signer, + } + } + if _, err := BuildDVNInstruction(base()); err != nil { + t.Fatalf("BuildDVNInstruction rejected the valid request: %v", err) + } + tooLarge := new(big.Int).Lsh(big.NewInt(1), maxUint256Bits) + for _, test := range []struct { + name string + mutate func(*DVNRequest) + }{ + {"zero vid", func(request *DVNRequest) { request.VID = 0 }}, + {"nil expiration", func(request *DVNRequest) { request.Expiration = nil }}, + {"zero expiration", func(request *DVNRequest) { request.Expiration = big.NewInt(0) }}, + {"negative expiration", func(request *DVNRequest) { request.Expiration = big.NewInt(-1) }}, + {"expiration above uint256", func(request *DVNRequest) { request.Expiration = tooLarge }}, + {"zero receive ULN", func(request *DVNRequest) { request.ReceiveULN = common.Address{} }}, + {"short packet header", func(request *DVNRequest) { + request.Packet = Packet{Header: packet.Header[:PacketHeaderBytes-1], PayloadHash: packet.PayloadHash} + }}, + {"missing signer", func(request *DVNRequest) { request.Signer = nil }}, + } { + t.Run(test.name, func(t *testing.T) { + request := base() + test.mutate(&request) + if _, err := BuildDVNInstruction(request); err == nil { + t.Fatal("BuildDVNInstruction accepted an invalid request") + } + }) + } +} + +func TestEncodersRejectUnencodableInput(t *testing.T) { + document := loadVectors(t) + packet, err := DecodePacket(mustHex(t, document.LayerZeroPackets[0].EncodedPacket)) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + if _, err := EncodeDVNExecute(DVNInstruction{VID: packet.DestinationEID, Expiration: nil}); err == nil { + t.Error("EncodeDVNExecute accepted a nil expiration") + } + nonEVM := packet + nonEVM.Receiver[0] = 1 + if _, err := EncodeExecutorSubmission(nonEVM, vectorGasLimit); err == nil { + t.Error("EncodeExecutorSubmission accepted a non-EVM receiver") + } +} + +func eventTopic(t *testing.T, contract interface { + EventTopic(string) (common.Hash, error) +}, name string) common.Hash { + t.Helper() + topic, err := contract.EventTopic(name) + if err != nil { + t.Fatalf("event topic %s: %v", name, err) + } + return topic +} diff --git a/go-runtime/internal/layerzero/plan.go b/go-runtime/internal/layerzero/plan.go new file mode 100644 index 0000000..e04f98d --- /dev/null +++ b/go-runtime/internal/layerzero/plan.go @@ -0,0 +1,155 @@ +package layerzero + +import ( + "bytes" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// DefaultTransactionGas is the submission gas of every LayerZero stage +// transaction, as the Python worker pins it. +const DefaultTransactionGas uint64 = 5_000_000 + +// PlanConfig is the destination-side LayerZero configuration of one chain, as +// the worker config document carries it. +type PlanConfig struct { + // DVN is the destination DVN that executes the verification instruction. + DVN common.Address + // ReceiveULN is the destination ReceiveUln302 that commits the payload hash. + ReceiveULN common.Address + // Executor is the destination Executor that delivers the message. + Executor common.Address + // Confirmations is the source confirmation count the DVN instruction attests. + Confirmations uint64 + // Expiration is the unix second the DVN instruction stays valid until. + Expiration *big.Int + // GasLimit is the gas limit of the executor delivery call. + GasLimit uint64 + // TransactionGas is the submission gas of each stage transaction; zero uses + // DefaultTransactionGas. + TransactionGas uint64 +} + +// Action is one destination submission of the LayerZero delivery sequence. +type Action struct { + // Stage is one of StageDVNExecute, StageCommitVerification, or + // StageExecutorExecute. + Stage string + // Target is the contract the submission calls. + Target common.Address + // CallData is the full calldata including the selector. + CallData []byte + // ExpectedTopic is the official event topic0 the stage receipt must carry. + ExpectedTopic common.Hash + // Gas is the transaction gas limit of the submission. + Gas uint64 +} + +// Plan returns the ordered destination submissions of one LayerZero delivery: +// DVN.execute, ReceiveUln302.commitVerification, then Executor.execute302. +// +// The DVN verification instruction uses the packet's destination EID as its +// VID, exactly as the Python worker builds it from the destination chain it +// selected for the packet. +func Plan(packet Packet, config PlanConfig, signer DigestSigner) ([]Action, error) { + if config.GasLimit == 0 { + return nil, errors.New("layerzero: executor gas limit is out of uint128 range") + } + instruction, err := BuildDVNInstruction(DVNRequest{ + VID: packet.DestinationEID, + ReceiveULN: config.ReceiveULN, + Packet: packet, + Confirmations: config.Confirmations, + Expiration: config.Expiration, + Signer: signer, + }) + if err != nil { + return nil, err + } + dvnExecute, err := EncodeDVNExecute(instruction) + if err != nil { + return nil, err + } + commitVerification, err := EncodeCommitVerification(packet) + if err != nil { + return nil, err + } + executorExecute, err := EncodeExecutorSubmission(packet, config.GasLimit) + if err != nil { + return nil, err + } + transactionGas := config.TransactionGas + if transactionGas == 0 { + transactionGas = DefaultTransactionGas + } + return []Action{ + { + Stage: StageDVNExecute, + Target: config.DVN, + CallData: dvnExecute, + ExpectedTopic: topicHash(PayloadVerifiedTopic), + Gas: transactionGas, + }, + { + Stage: StageCommitVerification, + Target: config.ReceiveULN, + CallData: commitVerification, + ExpectedTopic: topicHash(PacketVerifiedTopic), + Gas: transactionGas, + }, + { + Stage: StageExecutorExecute, + Target: config.Executor, + CallData: executorExecute, + ExpectedTopic: topicHash(PacketDeliveredTopic), + Gas: transactionGas, + }, + }, nil +} + +// ExtractPacket returns the packet a receipt's PacketSent log carries and +// checks that the log was emitted for the expected source EID. +func ExtractPacket(receipt *types.Receipt, sourceEID uint32) (Packet, error) { + if receipt == nil { + return Packet{}, errors.New("layerzero: receipt is missing") + } + sentTopic := topicHash(PacketSentTopic) + var packet Packet + found := false + for index := range receipt.Logs { + log := receipt.Logs[index] + if log == nil || len(log.Topics) == 0 || log.Topics[0] != sentTopic { + continue + } + values, err := endpointV2Contract.UnpackLog("PacketSent", log) + if err != nil { + return Packet{}, err + } + encoded, ok := values["encodedPayload"].([]byte) + if !ok { + return Packet{}, fmt.Errorf("layerzero: PacketSent log carries %T, want bytes", values["encodedPayload"]) + } + decoded, err := DecodePacket(encoded) + if err != nil { + return Packet{}, err + } + if decoded.SourceEID != sourceEID { + return Packet{}, fmt.Errorf("layerzero: PacketSent source EID %d does not match %d", decoded.SourceEID, sourceEID) + } + if found { + if bytes.Equal(packet.Encoded, decoded.Encoded) { + continue + } + return Packet{}, errors.New("layerzero: receipt carries more than one PacketSent packet") + } + packet, found = decoded, true + } + if !found { + return Packet{}, fmt.Errorf("layerzero: receipt carries no PacketSent log for EID %d", sourceEID) + } + return packet, nil +} diff --git a/go-runtime/internal/layerzero/plan_test.go b/go-runtime/internal/layerzero/plan_test.go new file mode 100644 index 0000000..29ad45a --- /dev/null +++ b/go-runtime/internal/layerzero/plan_test.go @@ -0,0 +1,210 @@ +package layerzero + +import ( + "bytes" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// TestPlanMatchesWorkerSubmissionSequence checks the three ordered submissions +// against the packet vectors: the stage names, the targets, the calldata (byte +// identical to the Python worker's), and the official event each stage must +// produce. +func TestPlanMatchesWorkerSubmissionSequence(t *testing.T) { + document := loadVectors(t) + signer := newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"]) + for _, vector := range document.LayerZeroPackets { + packet, err := DecodePacket(mustHex(t, vector.EncodedPacket)) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + destination := PlanConfig{ + ReceiveULN: common.HexToAddress(vector.DVNInstruction.Target), + DVN: common.HexToAddress("0x" + strings.Repeat("dd", 20)), + Executor: common.HexToAddress("0x" + strings.Repeat("ee", 20)), + Confirmations: vectorConfirmations, + Expiration: big.NewInt(vectorExpiration), + GasLimit: vectorGasLimit, + } + actions, err := Plan(packet, destination, signer) + if err != nil { + t.Fatalf("Plan: %v", err) + } + wantStages := []string{"dvn_execute", "commit_verification", "executor_execute"} + if len(actions) != len(wantStages) { + t.Fatalf("Plan returned %d actions, want %d", len(actions), len(wantStages)) + } + wantTargets := []common.Address{destination.DVN, destination.ReceiveULN, destination.Executor} + wantCalldata := []string{ + vector.DVNExecuteCalldata, + vector.CommitVerificationCalldata, + vector.ExecutorCalldata, + } + wantTopics := []common.Hash{ + topicHash(PayloadVerifiedTopic), + topicHash(PacketVerifiedTopic), + topicHash(PacketDeliveredTopic), + } + wantSelectors := []string{ + "execute((uint32,address,bytes,uint256,bytes)[])", + "commitVerification(bytes,bytes32)", + "execute302((address,(uint32,bytes32,uint64),bytes32,bytes,bytes,uint256))", + } + for index, action := range actions { + if action.Stage != wantStages[index] || action.Stage != Stages[index] { + t.Errorf("action %d stage = %q, want %q", index, action.Stage, wantStages[index]) + } + if action.Target != wantTargets[index] { + t.Errorf("%s target = %s, want %s", action.Stage, action.Target, wantTargets[index]) + } + checkHex(t, action.Stage+" calldata", action.CallData, wantCalldata[index]) + if action.ExpectedTopic != wantTopics[index] { + t.Errorf("%s expected topic = %s, want %s", action.Stage, action.ExpectedTopic, wantTopics[index]) + } + checkHex(t, action.Stage+" selector", action.CallData[:4], hexOf(selector(wantSelectors[index]))) + if action.Gas != DefaultTransactionGas { + t.Errorf("%s transaction gas = %d, want %d", action.Stage, action.Gas, DefaultTransactionGas) + } + } + } +} + +// TestPlanHonoursTransactionGasOverride checks the caller's submission gas on +// every stage of the sequence. +func TestPlanHonoursTransactionGasOverride(t *testing.T) { + document := loadVectors(t) + vector := document.LayerZeroPackets[0] + packet, err := DecodePacket(mustHex(t, vector.EncodedPacket)) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + const override = 250_000 + actions, err := Plan(packet, PlanConfig{ + ReceiveULN: common.HexToAddress(vector.DVNInstruction.Target), + DVN: common.HexToAddress("0x" + strings.Repeat("dd", 20)), + Executor: common.HexToAddress("0x" + strings.Repeat("ee", 20)), + Confirmations: vectorConfirmations, + Expiration: big.NewInt(vectorExpiration), + GasLimit: vectorGasLimit, + TransactionGas: override, + }, newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"])) + if err != nil { + t.Fatalf("Plan: %v", err) + } + for _, action := range actions { + if action.Gas != override { + t.Errorf("%s transaction gas = %d, want %d", action.Stage, action.Gas, override) + } + } +} + +func TestPlanRejectsInconsistentDestinations(t *testing.T) { + document := loadVectors(t) + vector := document.LayerZeroPackets[0] + packet, err := DecodePacket(mustHex(t, vector.EncodedPacket)) + if err != nil { + t.Fatalf("DecodePacket: %v", err) + } + signer := newTestSigner(t, document.Constants.FixtureKeys["dvn_signer"]) + base := func() PlanConfig { + return PlanConfig{ + ReceiveULN: common.HexToAddress(vector.DVNInstruction.Target), + DVN: common.HexToAddress("0x" + strings.Repeat("dd", 20)), + Executor: common.HexToAddress("0x" + strings.Repeat("ee", 20)), + Confirmations: vectorConfirmations, + Expiration: big.NewInt(vectorExpiration), + GasLimit: vectorGasLimit, + } + } + if _, err := Plan(packet, base(), signer); err != nil { + t.Fatalf("Plan rejected the consistent destination: %v", err) + } + for _, test := range []struct { + name string + mutate func(*PlanConfig) + signer DigestSigner + }{ + {"zero gas limit", func(config *PlanConfig) { config.GasLimit = 0 }, signer}, + {"missing signer", func(*PlanConfig) {}, nil}, + {"expired instruction", func(config *PlanConfig) { config.Expiration = big.NewInt(0) }, signer}, + {"zero receive ULN", func(config *PlanConfig) { config.ReceiveULN = common.Address{} }, signer}, + } { + t.Run(test.name, func(t *testing.T) { + destination := base() + test.mutate(&destination) + if _, err := Plan(packet, destination, test.signer); err == nil { + t.Fatal("Plan accepted an inconsistent destination") + } + }) + } +} + +func TestExtractPacketReadsPacketSentLog(t *testing.T) { + document := loadVectors(t) + vector := document.LayerZeroPackets[0] + encoded := mustHex(t, vector.EncodedPacket) + endpoint := common.HexToAddress("0x" + strings.Repeat("ab", 20)) + receipt := &types.Receipt{Logs: []*types.Log{ + { + Address: endpoint, + Topics: []common.Hash{common.HexToHash("0x" + strings.Repeat("00", 32))}, + Data: []byte{0x01}, + }, + packetSentLog(t, endpoint, encoded), + }} + packet, err := ExtractPacket(receipt, vector.SourceEID) + if err != nil { + t.Fatalf("ExtractPacket: %v", err) + } + checkHex(t, "encoded", packet.Encoded, vector.EncodedPacket) + checkHex(t, "guid", packet.GUID[:], vector.GUID) + checkHex(t, "payload_hash", packet.PayloadHash[:], vector.PayloadHash) +} + +func TestExtractPacketRejectsUnusableReceipts(t *testing.T) { + document := loadVectors(t) + vector := document.LayerZeroPackets[0] + encoded := mustHex(t, vector.EncodedPacket) + endpoint := common.HexToAddress("0x" + strings.Repeat("ab", 20)) + second := bytes.Clone(encoded) + second[PacketGUIDOffset] ^= 0xff + broken := bytes.Clone(encoded) + broken[49] = 1 + for _, test := range []struct { + name string + receipt *types.Receipt + source uint32 + }{ + {"missing receipt", nil, vector.SourceEID}, + {"no PacketSent log", &types.Receipt{}, vector.SourceEID}, + {"wrong source EID", &types.Receipt{Logs: []*types.Log{packetSentLog(t, endpoint, encoded)}}, vector.SourceEID + 1}, + {"non-EVM receiver", &types.Receipt{Logs: []*types.Log{packetSentLog(t, endpoint, broken)}}, vector.SourceEID}, + {"two distinct packets", &types.Receipt{Logs: []*types.Log{ + packetSentLog(t, endpoint, encoded), + packetSentLog(t, endpoint, second), + }}, vector.SourceEID}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := ExtractPacket(test.receipt, test.source); err == nil { + t.Fatal("ExtractPacket accepted an unusable receipt") + } + }) + } +} + +func packetSentLog(t *testing.T, endpoint common.Address, encodedPacket []byte) *types.Log { + t.Helper() + data, err := endpointV2Contract.ABI().Events["PacketSent"].Inputs.Pack(encodedPacket, []byte{}, endpoint) + if err != nil { + t.Fatalf("pack PacketSent log: %v", err) + } + return &types.Log{ + Address: endpoint, + Topics: []common.Hash{topicHash(PacketSentTopic)}, + Data: data, + } +} diff --git a/go-runtime/internal/runner/abi_shapes.go b/go-runtime/internal/runner/abi_shapes.go new file mode 100644 index 0000000..45d462a --- /dev/null +++ b/go-runtime/internal/runner/abi_shapes.go @@ -0,0 +1,74 @@ +package runner + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" +) + +// adapterRequestABI is the request tuple of LayerZeroAdapter.sendSource, +// forwardInFlight, and quoteForward. +type adapterRequestABI struct { + Verifiers []common.Address `abi:"verifiers"` + ProfileHashes [][32]byte `abi:"profileHashes"` + EvidenceHashes [][32]byte `abi:"evidenceHashes"` + TransitionHashes [][32]byte `abi:"transitionHashes"` + CurrentProfileHash [32]byte `abi:"currentProfileHash"` + CurrentTransitionHash [32]byte `abi:"currentTransitionHash"` + Options []byte `abi:"options"` +} + +// hyperlaneRequestABI is the request tuple of HyperlaneAdapter.sendSourceBundle, +// forwardInFlightBundle, and quoteBundle: the same tuple without options. +type hyperlaneRequestABI struct { + Verifiers []common.Address `abi:"verifiers"` + ProfileHashes [][32]byte `abi:"profileHashes"` + EvidenceHashes [][32]byte `abi:"evidenceHashes"` + TransitionHashes [][32]byte `abi:"transitionHashes"` + CurrentProfileHash [32]byte `abi:"currentProfileHash"` + CurrentTransitionHash [32]byte `abi:"currentTransitionHash"` +} + +// messagingFeeABI is the LayerZero MessagingFee tuple (nativeFee, lzTokenFee) +// that the adapter's quote functions return. +type messagingFeeABI struct { + NativeFee *big.Int `abi:"nativeFee"` + LzTokenFee *big.Int `abi:"lzTokenFee"` +} + +// hyperlaneInnerABI is the DeliveryBundle inner encoding of HyperlaneAdapter. +type hyperlaneInnerABI struct { + CurrentProfile [32]byte `abi:"currentProfile"` + CurrentTransition [32]byte `abi:"currentTransition"` + PriorProfiles [][32]byte `abi:"priorProfiles"` + PriorEvidence [][32]byte `abi:"priorEvidence"` + PriorTransitions [][32]byte `abi:"priorTransitions"` +} + +// hyperlaneBodyABI is the adapter message body: (uint8 kind, bytes inner). +type hyperlaneBodyABI struct { + Kind uint8 `abi:"kind"` + Inner []byte `abi:"inner"` +} + +// hyperlaneEvidenceABI is the preimage of the Hyperlane evidence hash. +type hyperlaneEvidenceABI struct { + Domain uint32 `abi:"domain"` + Sender [32]byte `abi:"sender"` + Body []byte `abi:"body"` +} + +// The fragments below mirror the Solidity types the adapters and the Python +// coordinator encode (src/xir_lab/native/multihop_runner.py _dispatch_hop). +const ( + hyperlaneInnerFragments = `[{"name":"currentProfile","type":"bytes32"},` + + `{"name":"currentTransition","type":"bytes32"},` + + `{"name":"priorProfiles","type":"bytes32[]"},` + + `{"name":"priorEvidence","type":"bytes32[]"},` + + `{"name":"priorTransitions","type":"bytes32[]"}]` + + hyperlaneBodyFragments = `[{"name":"kind","type":"uint8"},{"name":"inner","type":"bytes"}]` + + hyperlaneEvidenceFragments = `[{"name":"domain","type":"uint32"},` + + `{"name":"sender","type":"bytes32"},{"name":"body","type":"bytes"}]` +) diff --git a/go-runtime/internal/runner/carrier.go b/go-runtime/internal/runner/carrier.go new file mode 100644 index 0000000..c0a8f85 --- /dev/null +++ b/go-runtime/internal/runner/carrier.go @@ -0,0 +1,90 @@ +package runner + +import ( + "context" + "fmt" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// Evidence is one hop's on-chain evidence as the destination adapter records it. +type Evidence struct { + // Hash is the evidence hash the XIR receipt commits to. + Hash [32]byte + // NativeMessageID is the carrier message identifier, for the ledger only. + NativeMessageID string + // Detail is copied into the durable stage row. + Detail map[string]any +} + +// HopRequest is everything a carrier needs to move one hop. +type HopRequest struct { + Route string + AttemptID string + HopIndex int + Protocol string + SourceRole string + DestinationRole string + SourceAdapter *bound + DestinationAdapter *bound + Verifiers []common.Address + PriorReceipts []xir.Receipt + CurrentProfile [32]byte + CurrentTransition [32]byte +} + +// DispatchResult is the outcome of one dispatch plus the protocol-private data +// the confirmation step needs. +type DispatchResult struct { + Evidence Evidence + Stage string + Transaction string + delivery any +} + +// Carrier moves one hop and confirms the destination adapter accepted it. +type Carrier interface { + // Dispatch submits the source-chain dispatch of one hop. + Dispatch(ctx context.Context, request HopRequest) (DispatchResult, error) + // Confirm delivers the message when this runtime owns the carrier's + // chain-external roles, then waits until the destination adapter reports + // every receipt of the current bundle as verified. + Confirm(ctx context.Context, request HopRequest, dispatched DispatchResult) error +} + +// bundleWithCurrent returns the receipts the destination adapter must accept: +// the prior receipts followed by this hop's receipt. +func bundleWithCurrent(request HopRequest, evidence Evidence) []xir.Receipt { + bundle := make([]xir.Receipt, 0, len(request.PriorReceipts)+1) + bundle = append(bundle, request.PriorReceipts...) + bundle = append(bundle, xir.Receipt{ + ProfileHash: request.CurrentProfile, + EvidenceHash: evidence.Hash, + TransitionHash: request.CurrentTransition, + }) + return bundle +} + +// dispatchFee reuses the original payable amount on recovery. Re-quoting an +// already frozen dispatch against newer gas prices would change its identity. +func dispatchFee(store *state.Store, id string, quote func() (*big.Int, error)) (*big.Int, error) { + action, err := store.Action(id) + if err != nil { + return nil, err + } + if action == nil { + return quote() + } + if err := state.VerifyActionIdentity(action); err != nil { + return nil, err + } + fee, ok := new(big.Int).SetString(action.Value, 10) + if !ok || fee.Sign() < 0 { + return nil, fmt.Errorf("runner: invalid frozen dispatch fee") + } + return fee, nil +} diff --git a/go-runtime/internal/runner/chain.go b/go-runtime/internal/runner/chain.go new file mode 100644 index 0000000..bf21eaf --- /dev/null +++ b/go-runtime/internal/runner/chain.go @@ -0,0 +1,292 @@ +package runner + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +// bound is one deployed contract on one chain, with the client and transactor +// that reach it. +type bound struct { + role string + key string + address common.Address + artifact artifacts.Artifact + client *evm.Client + transactor *evm.Transactor + gasLimit uint64 +} + +// call performs an eth_call and returns the decoded outputs. +func (b *bound) call(ctx context.Context, name string, args ...any) ([]any, error) { + data, err := b.artifact.ABI.PackCall(name, args...) + if err != nil { + return nil, err + } + output, err := b.client.CallContract(ctx, b.address, data) + if err != nil { + return nil, fmt.Errorf("runner: call %s.%s on %s: %w", b.key, name, b.role, err) + } + method, ok := b.artifact.ABI.ABI().Methods[name] + if !ok { + return nil, fmt.Errorf("runner: %s has no method %q", b.key, name) + } + if len(method.Outputs) == 0 { + return nil, nil + } + return method.Outputs.Unpack(output) +} + +// send executes one state-changing call through the durable transactor. +func (b *bound) send( + ctx context.Context, + attemptID string, + stage string, + name string, + args []any, + value *big.Int, +) (state.ActionResult, error) { + return b.sendWithIntent(ctx, attemptID, stage, name, args, value, nil) +} + +func (b *bound) sendWithIntent(ctx context.Context, attemptID, stage, name string, args []any, value *big.Int, detail map[string]any) (state.ActionResult, error) { + data, err := b.artifact.ABI.PackCall(name, args...) + if err != nil { + return state.ActionResult{}, err + } + result, err := b.transactor.Execute(ctx, evm.TxRequest{ + ActionID: actionID(attemptID, stage), + IntentDetail: detail, + AttemptID: attemptID, + Stage: stage, + ChainRole: b.role, + To: b.address, + Data: data, + Value: value, + Gas: b.gasLimit, + }) + if err != nil { + return state.ActionResult{}, fmt.Errorf("runner: %s.%s on %s: %w", b.key, name, b.role, err) + } + return result, nil +} + +// events returns the logs of the mined receipt that match the given event. +// The durable receipt file remains the evidence artifact; this read only drives +// control flow. +func (b *bound) events(ctx context.Context, result state.ActionResult, name string) ([]map[string]any, error) { + receipt, err := b.client.Receipt(ctx, common.HexToHash(result.TransactionHash)) + if err != nil { + return nil, err + } + topic, err := b.artifact.ABI.EventTopic(name) + if err != nil { + return nil, err + } + matches := make([]map[string]any, 0, 1) + for index := range receipt.Logs { + log := receipt.Logs[index] + if log.Address != b.address || len(log.Topics) == 0 || log.Topics[0] != topic { + continue + } + decoded, err := b.artifact.ABI.UnpackLog(name, log) + if err != nil { + return nil, err + } + matches = append(matches, decoded) + } + return matches, nil +} + +// actionID identifies one durable action inside an attempt. +func actionID(attemptID, stage string) string { + return attemptID + ":" + stage +} + +// artifactFor maps a deployment manifest key to the Forge artifact that +// implements it. Route keys carry the hop index, and the route string decides +// which carrier contract the adapter key refers to. +func artifactFor(key, route string) (string, string, error) { + switch key { + case "registry": + return "XIRRegistry.sol", "XIRRegistry", nil + case "gateway": + return "XIRGateway.sol", "XIRGateway", nil + case "receiver": + return "NativeMultihopReceiver.sol", "NativeMultihopReceiver", nil + case "transition_recorder": + return "NativeMultihopTransitionRecorder.sol", "NativeMultihopTransitionRecorder", nil + } + if !strings.HasPrefix(key, "route_") { + return "", "", fmt.Errorf("runner: unknown contract key %q", key) + } + parts := strings.Split(key, "_") + if len(parts) != 5 { + return "", "", fmt.Errorf("runner: malformed adapter key %q", key) + } + hopIndex, err := parseHopIndex(parts[3]) + if err != nil { + return "", "", fmt.Errorf("runner: adapter key %q: %w", key, err) + } + if hopIndex < 1 || hopIndex > len(route) { + return "", "", fmt.Errorf("runner: adapter key %q is outside route %q", key, route) + } + if route[hopIndex-1] == 'H' { + return "HyperlaneAdapter.sol", "HyperlaneAdapter", nil + } + return "LayerZeroAdapter.sol", "LayerZeroAdapter", nil +} + +func parseHopIndex(value string) (int, error) { + index := 0 + for _, char := range value { + if char < '0' || char > '9' { + return 0, fmt.Errorf("hop %q is not numeric", value) + } + index = index*10 + int(char-'0') + } + return index, nil +} + +// contractSet resolves and caches bound contracts for every chain. +type contractSet struct { + chains map[string]*roleChain + artifacts string + deployment *Deployment + route string + gasLimit uint64 + store *state.Store + rawRoot string +} + +// recordEvent appends one event row to the durable ledger. +func (s *contractSet) recordEvent(row state.EventRow) error { + return s.store.RecordEvent(row) +} + +// close releases every chain client. +func (s *contractSet) close() { + for _, chain := range s.chains { + chain.client.Close() + } +} + +// roleChain is one chain with its client, the runner transactor, and any +// chain-external agent transactors the carriers need. +type roleChain struct { + config ChainConfig + client *evm.Client + transactor *evm.Transactor + store *state.Store + rawRoot string + gasLimit uint64 + revealer *evm.Signer + bindings map[string]*bound + worker map[string]*evm.Transactor +} + +// transactorGasLimit returns the gas limit runtime transactions use. +func (c *roleChain) transactorGasLimit() uint64 { return c.gasLimit } + +// agentTransactor returns a transactor signed with one chain-external agent +// key, for example the LayerZero DVN/Executor key or the Hyperlane relayer key. +func (c *roleChain) agentTransactor(privateKey string) (*evm.Transactor, error) { + if existing, ok := c.worker[privateKey]; ok { + return existing, nil + } + signer, err := evm.NewSigner(privateKey, new(big.Int).SetUint64(c.config.ChainID)) + if err != nil { + return nil, fmt.Errorf("runner: agent signer for %s: %w", c.config.Role, err) + } + transactor, err := evm.NewTransactor(c.client, signer, c.store, c.rawRoot, 3*time.Minute) + if err != nil { + return nil, fmt.Errorf("runner: agent transactor for %s: %w", c.config.Role, err) + } + c.worker[privateKey] = transactor + return transactor, nil +} + +func (s *contractSet) chain(role string) (*roleChain, error) { + chain, ok := s.chains[role] + if !ok { + return nil, fmt.Errorf("runner: no configured chain for role %q", role) + } + return chain, nil +} + +// bind resolves one contract of the current route. +func (s *contractSet) bind(role, key string) (*bound, error) { + chain, err := s.chain(role) + if err != nil { + return nil, err + } + if existing, ok := chain.bindings[key]; ok { + return existing, nil + } + sourceName, contractName, err := artifactFor(key, s.route) + if err != nil { + return nil, err + } + address, err := s.deployment.Contract(role, key) + if err != nil { + return nil, err + } + artifact, err := artifacts.Load(s.artifacts, contractName) + if err != nil { + return nil, fmt.Errorf("runner: load %s: %w", sourceName, err) + } + resolved := &bound{ + role: role, + key: key, + address: address, + artifact: artifact, + client: chain.client, + transactor: chain.transactor, + gasLimit: s.gasLimit, + } + chain.bindings[key] = resolved + return resolved, nil +} + +// SetRoute selects the route whose adapter artifacts are resolved. +func (s *contractSet) SetRoute(route string) { s.route = route } + +// infrastructure resolves one protocol infrastructure address of a chain. +func (s *contractSet) infrastructure(ctx context.Context, artifactRoot, role, key, sourceName, contractName string) (*bound, error) { + chain, err := s.chain(role) + if err != nil { + return nil, err + } + cacheKey := "infra:" + key + if existing, ok := chain.bindings[cacheKey]; ok { + return existing, nil + } + address, err := s.deployment.Infra(role, key) + if err != nil { + return nil, err + } + artifact, err := artifacts.Load(artifactRoot, contractName) + if err != nil { + return nil, fmt.Errorf("runner: load %s: %w", sourceName, err) + } + resolved := &bound{ + role: role, + key: cacheKey, + address: address, + artifact: artifact, + client: chain.client, + transactor: chain.transactor, + gasLimit: s.gasLimit, + } + chain.bindings[cacheKey] = resolved + return resolved, nil +} diff --git a/go-runtime/internal/runner/e2e_test.go b/go-runtime/internal/runner/e2e_test.go new file mode 100644 index 0000000..57ed068 --- /dev/null +++ b/go-runtime/internal/runner/e2e_test.go @@ -0,0 +1,472 @@ +package runner + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/deploy" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/lab" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// The integration tests deploy the real Hyperlane and LayerZero contract stacks +// from Forge artifacts onto disposable anvil chains with the frozen local chain +// ids, then execute a multihop attempt through them. They are the runtime's +// end-to-end proof; the frozen five-chain Besu topology remains the environment +// of record for published measurements. + +// testRoute is one preregistered route of the frozen order (H, L, HH, HHH, +// HHHH, HL, HLH, HLHL, LHLH, HHL, HHHL). "HL" needs three chains and exercises +// Hyperlane as the source carrier and LayerZero as the destination carrier. +const testRoute = "HL" + +type anvilLab struct { + root string + statePath string + rawRoot string + document string + keys KeySet + roles deploy.RoleAddresses + chains []*lab.Chain + chainSpecs []deploy.ChainSpec + byRole map[string]*lab.Chain +} + +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "contracts", "src", "XIRGateway.sol")); err != nil { + t.Fatalf("repository root %s does not hold the contracts: %v", root, err) + } + return root +} + +func requireArtifacts(t *testing.T, root string) { + t.Helper() + required := []string{ + filepath.Join(root, "contracts", "out", "XIRGateway.sol", "XIRGateway.json"), + filepath.Join(root, "protocol-projects", "hyperlane-native", "out", "Mailbox.sol", "Mailbox.json"), + filepath.Join(root, "protocol-projects", "layerzero-native", "out", "EndpointV2.sol", "EndpointV2.json"), + } + for _, path := range required { + if _, err := os.Stat(path); err != nil { + if os.Getenv("XIR_REQUIRE_E2E") == "1" { + t.Fatalf("required protocol artifact missing: %s: %v", path, err) + } + t.Skipf("integration test needs Forge artifacts: run `forge build --root contracts` and `scripts/bootstrap_native_protocols.sh` (%v)", err) + } + } +} + +func freshKey(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key, "0x" + hex.EncodeToString(crypto.FromECDSA(key)) +} + +func addressOf(key *ecdsa.PrivateKey) common.Address { + return crypto.PubkeyToAddress(key.PublicKey) +} + +func startAnvilLab(t *testing.T, routes []string) *anvilLab { + t.Helper() + root := repoRoot(t) + requireArtifacts(t, root) + if lab.AnvilPath("") == "" { + if os.Getenv("XIR_REQUIRE_E2E") == "1" { + t.Fatal("required anvil binary missing") + } + t.Skip("integration test needs the anvil binary") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + deployerKey, deployerKeyHex := freshKey(t) + runnerKey, runnerKeyHex := freshKey(t) + rootKey, rootKeyHex := freshKey(t) + workerKey, workerKeyHex := freshKey(t) + validatorKey, validatorKeyHex := freshKey(t) + relayerKey, relayerKeyHex := freshKey(t) + runnerAddress := addressOf(runnerKey) + rootAddress := addressOf(rootKey) + workerAddress := addressOf(workerKey) + validatorAddress := addressOf(validatorKey) + relayerAddress := addressOf(relayerKey) + + specs := []lab.Spec{ + {Role: "a", ChainID: 3133701, Fund: []lab.FundRequest{}}, + {Role: "b", ChainID: 3133702}, + {Role: "c", ChainID: 3133703}, + } + fundAddresses := []common.Address{ + addressOf(deployerKey), runnerAddress, rootAddress, workerAddress, + validatorAddress, relayerAddress, + } + for index := range specs { + for _, address := range fundAddresses { + specs[index].Fund = append(specs[index].Fund, lab.FundRequest{ + Address: address.Hex(), + Wei: "0x3635c9adc5dea00000", // 1000 ether + }) + } + } + chains, err := lab.StartChains(ctx, specs, lab.Options{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("start chains: %v", err) + } + lab := &anvilLab{ + root: root, + chains: chains, + byRole: map[string]*lab.Chain{}, + statePath: filepath.Join(t.TempDir(), "runner.sqlite"), + rawRoot: filepath.Join(t.TempDir(), "raw"), + document: filepath.Join(t.TempDir(), "deployment.json"), + keys: KeySet{ + Runner: runnerKeyHex, + RootSigner: rootKeyHex, + LayerZeroWorker: workerKeyHex, + HyperlaneValidator: validatorKeyHex, + HyperlaneRelayer: relayerKeyHex, + }, + roles: deploy.RoleAddresses{ + Runner: runnerAddress, + RootSigner: rootAddress, + LayerZeroWorker: workerAddress, + HyperlaneValidator: validatorAddress, + HyperlaneRelayer: relayerAddress, + }, + } + eids := map[string]uint32{"a": 49001, "b": 49002, "c": 49003} + for index, chain := range chains { + role := specs[index].Role + lab.byRole[role] = chain + lab.chainSpecs = append(lab.chainSpecs, deploy.ChainSpec{ + Role: role, + ChainID: chain.ChainID, + RPCEndpoint: chain.URL, + HyperlaneDomain: uint32(chain.ChainID), + LayerZeroEID: eids[role], + }) + } + if _, err := deploy.Deploy(ctx, lab.chainSpecs, deploy.Options{ + ArtifactsRoot: filepath.Join(root, "contracts", "out"), + ProtocolArtifactsRoot: filepath.Join(root, "protocol-projects"), + RuntimeRoot: filepath.Join(t.TempDir(), "deploy-runtime"), + DeployerKey: deployerKeyHex, + OutputPath: lab.document, + Routes: routes, + RoleAddresses: lab.roles, + }); err != nil { + lab.stop() + t.Fatalf("deploy application: %v", err) + } + t.Cleanup(lab.stop) + return lab +} + +func (l *anvilLab) stop() { + lab.StopChains(l.chains) +} + +func (l *anvilLab) config(t *testing.T, attempt Attempt, fault string) Config { + t.Helper() + keys := l.keys + return Config{ + Attempts: []Attempt{attempt}, + Chains: []ChainConfig{ + {Role: "a", ChainID: 3133701, RPCURL: l.byRole["a"].URL, HyperlaneDomain: 3133701, LayerZeroEID: 49001}, + {Role: "b", ChainID: 3133702, RPCURL: l.byRole["b"].URL, HyperlaneDomain: 3133702, LayerZeroEID: 49002}, + {Role: "c", ChainID: 3133703, RPCURL: l.byRole["c"].URL, HyperlaneDomain: 3133703, LayerZeroEID: 49003}, + }, + DeploymentPath: l.document, + ArtifactsRoot: filepath.Join(l.root, "contracts", "out"), + ProtocolRoot: filepath.Join(l.root, "protocol-projects"), + PayloadSchedule: xir.PayloadSchedule{MinimumBytes: 32, SizeBucketCount: 4, SizeStepBytes: 32}, + FixedSeed: "xir-go-runtime-integration", + PolicyLabel: policyLabel, + Timeout: 5 * time.Minute, + PollInterval: 200 * time.Millisecond, + StatePath: l.statePath, + RawRoot: l.rawRoot, + Keys: keys, + EmbeddedAgents: true, + GasLimit: defaultGasLimit, + FaultAfterStage: fault, + // anvil pins the RPC finalized tag at genesis and mines only when there is + // work, so the integration tests accept the mined block; the frozen Besu + // lab uses the tag with the private-QBFT fallback instead. + Finality: Finality{Mode: FinalityConfirmations}, + } +} + +func (l *anvilLab) client(t *testing.T, role string) *evm.Client { + t.Helper() + client, err := evm.Dial(context.Background(), l.byRole[role].URL, 30*time.Second) + if err != nil { + t.Fatalf("dial %s: %v", role, err) + } + t.Cleanup(client.Close) + return client +} + +func (l *anvilLab) deployment(t *testing.T) *Deployment { + t.Helper() + document, err := LoadDeployment(l.document) + if err != nil { + t.Fatalf("load deployment: %v", err) + } + return document +} + +// dumpDiagnostics reports the chain heights, the pending nonce of every role +// address, and the durable actions that have not succeeded yet. It runs only on +// a failed attempt, where the alternative is guessing why a submission stalled. +func (l *anvilLab) dumpDiagnostics(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + addresses := map[string]common.Address{ + "runner": l.roles.Runner, + "root": l.roles.RootSigner, + "lz_worker": l.roles.LayerZeroWorker, + "validator": l.roles.HyperlaneValidator, + "relayer": l.roles.HyperlaneRelayer, + } + for role, chain := range l.byRole { + client, err := evm.Dial(ctx, chain.URL, 10*time.Second) + if err != nil { + t.Logf("diagnostics: dial %s: %v", role, err) + continue + } + head, err := client.BlockNumber(ctx) + if err != nil { + t.Logf("diagnostics: head %s: %v", role, err) + } + for name, address := range addresses { + nonce, err := client.PendingNonce(ctx, address) + if err != nil { + t.Logf("diagnostics: %s/%s nonce: %v", role, name, err) + continue + } + t.Logf("diagnostics: chain %s head=%d %s(%s) pending nonce=%d", role, head, name, address.Hex(), nonce) + } + client.Close() + } + store, err := state.Open(l.statePath) + if err != nil { + t.Logf("diagnostics: open state: %v", err) + return + } + defer func() { _ = store.Close() }() + pending, err := store.PendingActions() + if err != nil { + t.Logf("diagnostics: pending actions: %v", err) + return + } + for _, action := range pending { + t.Logf("diagnostics: action %s chain=%s state=%s nonce=%d hash=%s target=%s", + action.ActionID, action.ChainRole, action.State, action.Nonce, action.TransactionHash, action.To) + } +} + +// countEvents counts the logs of one contract carrying the given topic. +func countEvents(t *testing.T, client *evm.Client, address common.Address, topic common.Hash) int { + t.Helper() + logs, err := client.Logs(context.Background(), ethereum.FilterQuery{ + FromBlock: big.NewInt(0), + Addresses: []common.Address{address}, + Topics: [][]common.Hash{{topic}}, + }) + if err != nil { + t.Fatalf("filter logs of %s: %v", address, err) + } + return len(logs) +} + +func callUint(t *testing.T, client *evm.Client, contract artifacts.Artifact, to common.Address, method string) *big.Int { + t.Helper() + data, err := contract.ABI.PackCall(method) + if err != nil { + t.Fatalf("pack %s: %v", method, err) + } + output, err := client.CallContract(context.Background(), to, data) + if err != nil { + t.Fatalf("call %s: %v", method, err) + } + var value *big.Int + if err := contract.ABI.UnpackOutputs(method, output, &value); err != nil { + t.Fatalf("decode %s: %v", method, err) + } + return value +} + +func TestAnvilRouteDeliversAndAppliesEffect(t *testing.T) { + lab := startAnvilLab(t, []string{testRoute}) + deployment := lab.deployment(t) + attempt := Attempt{ + AttemptID: "go-runtime-integration-" + testRoute, + Phase: "smoke", + Route: testRoute, + RouteSequence: 0, + SwitchCount: 1, + } + executor, err := New(lab.config(t, attempt, "")) + if err != nil { + t.Fatalf("new runner: %v", err) + } + defer executor.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + summary, err := executor.Run(ctx) + if err != nil { + lab.dumpDiagnostics(t) + t.Fatalf("run: %v (failures: %v)", err, summary.Failures) + } + if summary.Succeeded != 1 { + t.Fatalf("run summary: %+v", summary) + } + + receiverAddress, err := deployment.Contract("c", "receiver") + if err != nil { + t.Fatalf("receiver address: %v", err) + } + receiverArtifact, err := artifacts.Load(filepath.Join(lab.root, "contracts", "out"), "NativeMultihopReceiver") + if err != nil { + t.Fatalf("load receiver artifact: %v", err) + } + client := lab.client(t, "c") + if delivered := callUint(t, client, receiverArtifact, receiverAddress, "deliveryCount"); delivered.Int64() != 1 { + t.Fatalf("receiver delivery count is %s, want 1", delivered) + } + effectTopic, err := receiverArtifact.ABI.EventTopic("NativeMultihopEffectApplied") + if err != nil { + t.Fatalf("effect topic: %v", err) + } + if count := countEvents(t, client, receiverAddress, effectTopic); count != 1 { + t.Fatalf("destination emitted %d effect events, want 1", count) + } + + // The source chain dispatched exactly once through the Hyperlane adapter and + // the second chain dispatched exactly once through the LayerZero adapter. + hyperlaneAdapter, err := deployment.Contract("a", mustAdapterKey(testRoute, 1, "out")) + if err != nil { + t.Fatalf("hyperlane adapter: %v", err) + } + hyperlaneArtifact, err := artifacts.Load(filepath.Join(lab.root, "contracts", "out"), "HyperlaneAdapter") + if err != nil { + t.Fatalf("load hyperlane adapter artifact: %v", err) + } + dispatchTopic, err := hyperlaneArtifact.ABI.EventTopic("HyperlaneDispatched") + if err != nil { + t.Fatalf("dispatch topic: %v", err) + } + if count := countEvents(t, lab.client(t, "a"), hyperlaneAdapter, dispatchTopic); count != 1 { + t.Fatalf("source chain emitted %d Hyperlane dispatches, want 1", count) + } + layerzeroAdapter, err := deployment.Contract("b", mustAdapterKey(testRoute, 2, "out")) + if err != nil { + t.Fatalf("layerzero adapter: %v", err) + } + layerzeroArtifact, err := artifacts.Load(filepath.Join(lab.root, "contracts", "out"), "LayerZeroAdapter") + if err != nil { + t.Fatalf("load layerzero adapter artifact: %v", err) + } + forwardedTopic, err := layerzeroArtifact.ABI.EventTopic("VerifiedEvidenceForwarded") + if err != nil { + t.Fatalf("forwarded topic: %v", err) + } + if count := countEvents(t, lab.client(t, "b"), layerzeroAdapter, forwardedTopic); count != 1 { + t.Fatalf("intermediate chain emitted %d LayerZero dispatches, want 1", count) + } +} + +func TestAnvilRestartResumesWithoutDuplicateDispatch(t *testing.T) { + lab := startAnvilLab(t, []string{testRoute}) + deployment := lab.deployment(t) + attempt := Attempt{ + AttemptID: "go-runtime-restart-" + testRoute, + Phase: "smoke", + Route: testRoute, + RouteSequence: 1, + SwitchCount: 1, + } + interrupted, err := New(lab.config(t, attempt, dispatchStage(1, "H"))) + if err != nil { + t.Fatalf("new runner: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + summary, runErr := interrupted.Run(ctx) + cancel() + if closeErr := interrupted.Close(); closeErr != nil { + t.Fatalf("close interrupted runner: %v", closeErr) + } + if runErr == nil { + t.Fatalf("the injected fault did not stop the attempt: %+v", summary) + } + if len(summary.Failures) == 0 { + t.Fatalf("the interrupted run reported no failure") + } + + // A fresh runner over the same ledger resumes the attempt. The dispatch of + // hop 1 must not be broadcast a second time. + resumed, err := New(lab.config(t, attempt, "")) + if err != nil { + t.Fatalf("new resumed runner: %v", err) + } + defer resumed.Close() + resumeCtx, resumeCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer resumeCancel() + resumedSummary, err := resumed.Run(resumeCtx) + if err != nil { + t.Fatalf("resumed run: %v (failures: %v)", err, resumedSummary.Failures) + } + if resumedSummary.Succeeded != 1 { + t.Fatalf("resumed summary: %+v", resumedSummary) + } + + hyperlaneAdapter, err := deployment.Contract("a", mustAdapterKey(testRoute, 1, "out")) + if err != nil { + t.Fatalf("hyperlane adapter: %v", err) + } + hyperlaneArtifact, err := artifacts.Load(filepath.Join(lab.root, "contracts", "out"), "HyperlaneAdapter") + if err != nil { + t.Fatalf("load hyperlane adapter artifact: %v", err) + } + dispatchTopic, err := hyperlaneArtifact.ABI.EventTopic("HyperlaneDispatched") + if err != nil { + t.Fatalf("dispatch topic: %v", err) + } + if count := countEvents(t, lab.client(t, "a"), hyperlaneAdapter, dispatchTopic); count != 1 { + t.Fatalf("after recovery the source chain emitted %d dispatches, want 1", count) + } + receiverAddress, err := deployment.Contract("c", "receiver") + if err != nil { + t.Fatalf("receiver address: %v", err) + } + receiverArtifact, err := artifacts.Load(filepath.Join(lab.root, "contracts", "out"), "NativeMultihopReceiver") + if err != nil { + t.Fatalf("load receiver artifact: %v", err) + } + if delivered := callUint(t, lab.client(t, "c"), receiverArtifact, receiverAddress, "deliveryCount"); delivered.Int64() != 1 { + t.Fatalf("after recovery the receiver delivery count is %s, want 1", delivered) + } +} diff --git a/go-runtime/internal/runner/finality_test.go b/go-runtime/internal/runner/finality_test.go new file mode 100644 index 0000000..fc7ac98 --- /dev/null +++ b/go-runtime/internal/runner/finality_test.go @@ -0,0 +1,98 @@ +package runner + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/core/types" + + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/lab" +) + +func TestAwaitFinalityWaitsForSuccessorAndCanonicalBlock(t *testing.T) { + if lab.AnvilPath("") == "" { + t.Skip("anvil binary is unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + chains, err := lab.StartChains(ctx, []lab.Spec{{Role: "a", ChainID: 3133701}}, lab.Options{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("start chains: %v", err) + } + defer lab.StopChains(chains) + client, err := evm.Dial(ctx, chains[0].URL, 30*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + t.Fatalf("read head: %v", err) + } + receipt := &types.Receipt{BlockNumber: header.Number, BlockHash: header.Hash()} + + expected, err := (&Runner{config: Config{ + Finality: Finality{Mode: FinalityConfirmations, Confirmations: 1, Timeout: 30 * time.Second}, + }}).awaitFinality(ctx, client, receipt) + if err == nil { + t.Fatalf("a block with no successor was accepted as final (%s)", expected) + } + + accepting := &Runner{config: Config{Finality: Finality{Mode: FinalityConfirmations}}} + rule, err := accepting.awaitFinality(ctx, client, receipt) + if err != nil { + t.Fatalf("the zero-confirmation rule rejected a mined block: %v", err) + } + if rule != "confirmations-0" { + t.Fatalf("zero-confirmation rule reported %q", rule) + } + + immediate := &Runner{config: Config{Finality: Finality{Mode: FinalityNone}}} + if rule, err := immediate.awaitFinality(ctx, client, receipt); err != nil || rule != "none-test-only" { + t.Fatalf("the none rule reported %q with error %v", rule, err) + } + + if _, err := (&Runner{config: Config{Finality: Finality{Mode: "unknown"}}}).awaitFinality(ctx, client, receipt); err == nil { + t.Fatal("an unsupported finality mode was accepted") + } + + // A successor block satisfies the one-confirmation rule once it is mined. + waiting := &Runner{config: Config{ + Finality: Finality{Mode: FinalityConfirmations, Confirmations: 1, Timeout: 30 * time.Second}, + }} + done := make(chan struct { + rule string + err error + }, 1) + go func() { + rule, err := waiting.awaitFinality(ctx, client, receipt) + done <- struct { + rule string + err error + }{rule, err} + }() + select { + case result := <-done: + t.Fatalf("finality returned before the successor block existed: %q %v", result.rule, result.err) + case <-time.After(500 * time.Millisecond): + } + if err := chains[0].Mine(ctx, 1); err != nil { + t.Fatalf("mine: %v", err) + } + select { + case result := <-done: + if result.err != nil { + t.Fatalf("finality failed after the successor block: %v", result.err) + } + if result.rule != "confirmations-1" { + t.Fatalf("finality reported %q", result.rule) + } + case <-time.After(30 * time.Second): + t.Fatal("finality did not observe the successor block") + } + if head, err := client.BlockNumber(ctx); err != nil || head < 1 { + t.Fatalf("chain head is %d (err %v), want a mined successor", head, err) + } +} diff --git a/go-runtime/internal/runner/hyperlane_carrier.go b/go-runtime/internal/runner/hyperlane_carrier.go new file mode 100644 index 0000000..c021b1c --- /dev/null +++ b/go-runtime/internal/runner/hyperlane_carrier.go @@ -0,0 +1,384 @@ +package runner + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/hyperlane" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// hyperlaneDelivery is the protocol-private data one Hyperlane hop needs after +// dispatch: the dispatched message and the origin mailbox it came from. +type hyperlaneDelivery struct { + Message []byte + MessageID [32]byte + DestinationDomain uint32 + OriginDomain uint32 + OriginHook common.Address + InsertedIndex uint32 + SourceResult state.ActionResult + SourceRole string +} + +type hyperlaneCarrier struct { + chains *contractSet + protocolRoot string + domains map[string]uint32 + chainsByRole map[string]ChainConfig + validatorKey string + relayerKey string + embedded bool + poll time.Duration + timeout time.Duration + gasLimit uint64 +} + +func newHyperlaneCarrier( + chains *contractSet, + protocolRoot string, + configs []ChainConfig, + keys KeySet, + config Config, +) *hyperlaneCarrier { + domains := map[string]uint32{} + byRole := map[string]ChainConfig{} + for _, chain := range configs { + domains[chain.Role] = chain.HyperlaneDomain + byRole[chain.Role] = chain + } + return &hyperlaneCarrier{ + chains: chains, + protocolRoot: protocolRoot, + domains: domains, + chainsByRole: byRole, + validatorKey: keys.HyperlaneValidator, + relayerKey: keys.HyperlaneRelayer, + embedded: config.EmbeddedAgents, + poll: config.PollInterval, + timeout: config.Timeout, + gasLimit: config.GasLimit, + } +} + +// Dispatch submits sendSourceBundle (first hop) or forwardInFlightBundle. +func (c *hyperlaneCarrier) Dispatch(ctx context.Context, request HopRequest) (DispatchResult, error) { + adapter := request.SourceAdapter + profiles := make([][32]byte, 0, len(request.PriorReceipts)) + evidences := make([][32]byte, 0, len(request.PriorReceipts)) + transitions := make([][32]byte, 0, len(request.PriorReceipts)) + for _, receipt := range request.PriorReceipts { + profiles = append(profiles, receipt.ProfileHash) + evidences = append(evidences, receipt.EvidenceHash) + transitions = append(transitions, receipt.TransitionHash) + } + bundle := hyperlaneRequestABI{ + Verifiers: request.Verifiers, + ProfileHashes: profiles, + EvidenceHashes: evidences, + TransitionHashes: transitions, + CurrentProfileHash: request.CurrentProfile, + CurrentTransitionHash: request.CurrentTransition, + } + stage := dispatchStage(request.HopIndex, request.Protocol) + fee, err := dispatchFee(c.chains.store, actionID(request.AttemptID, stage), func() (*big.Int, error) { + quoted, err := adapter.call(ctx, "quoteBundle", bundle) + if err != nil { + return nil, err + } + return singleBigInt(quoted) + }) + if err != nil { + return DispatchResult{}, fmt.Errorf("runner: hyperlane dispatch fee: %w", err) + } + + name := "sendSourceBundle" + if request.HopIndex > 1 { + name = "forwardInFlightBundle" + } + result, err := adapter.send(ctx, request.AttemptID, stage, name, []any{bundle}, fee) + if err != nil { + return DispatchResult{}, err + } + evidence, messageID, err := c.evidenceFor(ctx, adapter, request, result) + if err != nil { + return DispatchResult{}, err + } + delivery, err := c.collectDelivery(ctx, adapter, request, result) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{ + Evidence: Evidence{ + Hash: evidence, + NativeMessageID: "0x" + common.Bytes2Hex(messageID[:]), + Detail: map[string]any{ + "protocol": "H", + "hop_index": request.HopIndex, + "native_message_id": messageID, + "quote_native_fee_wei": fee.String(), + "destination_domain": c.domains[request.DestinationRole], + }, + }, + Stage: stage, + Transaction: result.TransactionHash, + delivery: delivery, + }, nil +} + +// evidenceFor derives the Hyperlane evidence hash the adapter committed to and +// reads the dispatched message id from the adapter event. +func (c *hyperlaneCarrier) evidenceFor( + ctx context.Context, + adapter *bound, + request HopRequest, + result state.ActionResult, +) ([32]byte, [32]byte, error) { + inner := hyperlaneInnerABI{ + CurrentProfile: request.CurrentProfile, + CurrentTransition: request.CurrentTransition, + PriorProfiles: priorField(request.PriorReceipts, func(receipt xir.Receipt) [32]byte { return receipt.ProfileHash }), + PriorEvidence: priorField(request.PriorReceipts, func(receipt xir.Receipt) [32]byte { return receipt.EvidenceHash }), + PriorTransitions: priorField(request.PriorReceipts, func(receipt xir.Receipt) [32]byte { return receipt.TransitionHash }), + } + innerBytes, err := abiutil.PackArgument( + `{"name":"inner","type":"tuple","components":`+hyperlaneInnerFragments+`}`, inner, + ) + if err != nil { + return [32]byte{}, [32]byte{}, err + } + body, err := abiutil.PackEncode(hyperlaneBodyFragments, uint8(3), innerBytes) + if err != nil { + return [32]byte{}, [32]byte{}, err + } + domain := c.domains[request.SourceRole] + sender := bytes32FromAddress(adapter.address) + evidence := hyperlane.EvidenceHash(domain, sender, body) + + events, err := adapter.events(ctx, result, "HyperlaneDispatched") + if err != nil { + return [32]byte{}, [32]byte{}, err + } + if len(events) != 1 { + return [32]byte{}, [32]byte{}, fmt.Errorf( + "runner: hop %d dispatch emitted %d HyperlaneDispatched events", request.HopIndex, len(events), + ) + } + messageID, ok := events[0]["messageId"].([32]byte) + if !ok { + return [32]byte{}, [32]byte{}, fmt.Errorf("runner: HyperlaneDispatched messageId is missing") + } + return evidence, messageID, nil +} + +// collectDelivery reads the dispatched message and the origin tree hook that +// inserted it, so Confirm can reproduce the checkpoint. +func (c *hyperlaneCarrier) collectDelivery( + ctx context.Context, + adapter *bound, + request HopRequest, + result state.ActionResult, +) (hyperlaneDelivery, error) { + mailbox := &bound{ + role: adapter.role, + key: "mailbox", + client: adapter.client, + transactor: adapter.transactor, + gasLimit: adapter.gasLimit, + } + address, err := c.chains.deployment.Infra(request.SourceRole, "mailbox") + if err != nil { + return hyperlaneDelivery{}, err + } + mailbox.address = address + if mailbox.artifact, err = loadProtocol(c.protocolRoot, "Mailbox"); err != nil { + return hyperlaneDelivery{}, err + } + receipt, err := adapter.client.Receipt(ctx, common.HexToHash(result.TransactionHash)) + if err != nil { + return hyperlaneDelivery{}, err + } + dispatched, err := hyperlane.DispatchedMessage(receipt, mailbox.artifact.ABI) + if err != nil { + return hyperlaneDelivery{}, fmt.Errorf("runner: hop %d dispatch: %w", request.HopIndex, err) + } + hook := common.Address{} + index := uint32(0) + found := false + for _, log := range receipt.Logs { + if len(log.Topics) == 0 || log.Topics[0] != hyperlane.InsertedIntoTreeTopic { + continue + } + messageID, insertedIndex, ok := insertedIntoTree(log) + if !ok || messageID != dispatched.MessageID { + continue + } + hook = log.Address + index = insertedIndex + found = true + } + if !found { + return hyperlaneDelivery{}, fmt.Errorf( + "runner: hop %d dispatch was not inserted into an origin tree", request.HopIndex, + ) + } + return hyperlaneDelivery{ + Message: dispatched.Message, + MessageID: dispatched.MessageID, + DestinationDomain: dispatched.Destination, + OriginDomain: c.domains[request.SourceRole], + OriginHook: hook, + InsertedIndex: index, + SourceResult: result, + SourceRole: request.SourceRole, + }, nil +} + +// Confirm delivers the message with the runtime's validator/relayer roles and +// then waits for the destination adapter to verify the bundle. +func (c *hyperlaneCarrier) Confirm(ctx context.Context, request HopRequest, dispatched DispatchResult) error { + delivery, ok := dispatched.delivery.(hyperlaneDelivery) + if !ok { + return fmt.Errorf("runner: hyperlane dispatch result is missing delivery data") + } + if c.embedded { + if err := c.deliver(ctx, request, delivery); err != nil { + return err + } + } + return awaitVerified(ctx, c.chains, request, Evidence{Hash: dispatched.Evidence.Hash}, c.poll, c.timeout) +} + +// deliver signs the origin checkpoint and submits Mailbox.process on the +// destination chain. +func (c *hyperlaneCarrier) deliver(ctx context.Context, request HopRequest, delivery hyperlaneDelivery) error { + destination, err := c.chains.chain(request.DestinationRole) + if err != nil { + return err + } + source, err := c.chains.chain(delivery.SourceRole) + if err != nil { + return err + } + validator, err := evm.NewSigner(c.validatorKey, big.NewInt(int64(source.config.ChainID))) + if err != nil { + return fmt.Errorf("runner: hyperlane validator key: %w", err) + } + checkpoint, err := readCheckpoint(ctx, c.protocolRoot, source, delivery) + if err != nil { + return err + } + digest, err := hyperlane.CheckpointDigest( + delivery.OriginDomain, delivery.OriginHook, checkpoint.Root, checkpoint.Index, delivery.MessageID, + ) + if err != nil { + return err + } + signature, err := validator.SignPersonalDigest(digest) + if err != nil { + return fmt.Errorf("runner: sign hyperlane checkpoint: %w", err) + } + mailboxAddress, err := c.chains.deployment.Infra(request.DestinationRole, "mailbox") + if err != nil { + return err + } + plan, err := hyperlane.Plan(hyperlane.PlanRequest{ + Message: delivery.Message, + OriginMerkleTreeHook: delivery.OriginHook, + Checkpoint: hyperlane.Checkpoint{ + Domain: delivery.OriginDomain, + Root: checkpoint.Root, + Index: checkpoint.Index, + }, + Signatures: signature, + DestinationMailbox: mailboxAddress, + }) + if err != nil { + return fmt.Errorf("runner: hyperlane process plan: %w", err) + } + stage := hyperlaneProcessStage(request.HopIndex) + transactor, err := destination.agentTransactor(c.relayerKey) + if err != nil { + return err + } + result, err := transactor.Execute(ctx, evm.TxRequest{ + ActionID: actionID(request.AttemptID, stage), + AttemptID: request.AttemptID, + Stage: stage, + ChainRole: request.DestinationRole, + To: plan.Mailbox, + Data: plan.Calldata, + Gas: c.gasLimit, + }) + if err != nil { + return fmt.Errorf("runner: hyperlane process: %w", err) + } + mailbox := &bound{ + role: request.DestinationRole, + key: "mailbox", + address: plan.Mailbox, + client: destination.client, + transactor: transactor, + gasLimit: c.gasLimit, + } + if mailbox.artifact, err = loadProtocol(c.protocolRoot, "Mailbox"); err != nil { + return err + } + events, err := mailbox.events(ctx, result, "Process") + if err != nil { + return err + } + if len(events) != 1 { + return fmt.Errorf("runner: hop %d process emitted %d Process events", request.HopIndex, len(events)) + } + row, err := eventRow( + request.AttemptID, stage, "succeeded", "embedded-relayer", + request.DestinationRole, request.HopIndex, result.TransactionHash, + map[string]any{ + "validator": validator.Address().Hex(), + "origin_hook": delivery.OriginHook.Hex(), + "checkpoint_root": xir.FormatDigest(checkpoint.Root), + "checkpoint_index": checkpoint.Index, + "message_id": xir.FormatDigest(delivery.MessageID), + }, + ) + if err != nil { + return err + } + if err := c.chains.recordEvent(row); err != nil { + return err + } + return nil +} + +func priorField(receipts []xir.Receipt, pick func(xir.Receipt) [32]byte) [][32]byte { + values := make([][32]byte, 0, len(receipts)) + for _, receipt := range receipts { + values = append(values, pick(receipt)) + } + return values +} + +func bytes32FromAddress(address common.Address) [32]byte { + var out [32]byte + copy(out[12:], address.Bytes()) + return out +} + +func singleBigInt(values []any) (*big.Int, error) { + if len(values) != 1 { + return nil, fmt.Errorf("expected one value, got %d", len(values)) + } + value, ok := values[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("expected a numeric value, got %T", values[0]) + } + return value, nil +} diff --git a/go-runtime/internal/runner/layerzero_carrier.go b/go-runtime/internal/runner/layerzero_carrier.go new file mode 100644 index 0000000..775d51e --- /dev/null +++ b/go-runtime/internal/runner/layerzero_carrier.go @@ -0,0 +1,391 @@ +package runner + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/layerzero" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// nativeFee reads the native fee of a LayerZero quote. The adapter returns the +// MessagingFee tuple, matching src/xir_lab/native/multihop_runner.py, which +// takes element zero of the quoted tuple. +func nativeFee(quoted []any) (*big.Int, error) { + if len(quoted) != 1 { + return nil, fmt.Errorf("quote returned %d values", len(quoted)) + } + if fee, ok := quoted[0].(*big.Int); ok { + return fee, nil + } + fee, ok := abi.ConvertType(quoted[0], messagingFeeABI{}).(messagingFeeABI) + if !ok || fee.NativeFee == nil { + return nil, fmt.Errorf("quote returned %T without a native fee", quoted[0]) + } + return fee.NativeFee, nil +} + +const ( + // defaultConfirmations is the source confirmation count the DVN attests. + defaultConfirmations uint64 = 1 + // defaultDeliveryGasLimit is the executor gas limit of one delivery. + defaultDeliveryGasLimit uint64 = 1_500_000 +) + +// layerZeroDelivery is the protocol-private data one LayerZero hop needs after +// dispatch: the packet the source endpoint emitted. +type layerZeroDelivery struct { + Packet layerzero.Packet + SourceResult state.ActionResult + SourceRole string +} + +type layerZeroCarrier struct { + chains *contractSet + protocolRoot string + eids map[string]uint32 + chainsByRole map[string]ChainConfig + workerKey string + embedded bool + poll time.Duration + timeout time.Duration + confirmations uint64 + gasLimit uint64 + transactionGas uint64 + options []byte + faultAfterStage string +} + +func newLayerZeroCarrier( + chains *contractSet, + protocolRoot string, + configs []ChainConfig, + keys KeySet, + config Config, +) (*layerZeroCarrier, error) { + eids := map[string]uint32{} + byRole := map[string]ChainConfig{} + for _, chain := range configs { + eids[chain.Role] = chain.LayerZeroEID + byRole[chain.Role] = chain + } + options := config.OptionsOverride + if len(options) == 0 { + generated, err := layerzero.ReceiveOptions(1_500_000) + if err != nil { + return nil, fmt.Errorf("runner: layerzero options: %w", err) + } + options = generated + } + return &layerZeroCarrier{ + chains: chains, + protocolRoot: protocolRoot, + eids: eids, + chainsByRole: byRole, + workerKey: keys.LayerZeroWorker, + embedded: config.EmbeddedAgents, + poll: config.PollInterval, + timeout: config.Timeout, + confirmations: defaultConfirmations, + gasLimit: defaultDeliveryGasLimit, + transactionGas: layerzero.DefaultTransactionGas, + options: options, + faultAfterStage: config.FaultAfterStage, + }, nil +} + +// Dispatch submits sendSource (first hop) or forwardInFlight with the enforced +// executor options, and returns the LayerZero GUID reported by the adapter. +func (c *layerZeroCarrier) Dispatch(ctx context.Context, request HopRequest) (DispatchResult, error) { + adapter := request.SourceAdapter + profiles := make([][32]byte, 0, len(request.PriorReceipts)) + evidences := make([][32]byte, 0, len(request.PriorReceipts)) + transitions := make([][32]byte, 0, len(request.PriorReceipts)) + for _, receipt := range request.PriorReceipts { + profiles = append(profiles, receipt.ProfileHash) + evidences = append(evidences, receipt.EvidenceHash) + transitions = append(transitions, receipt.TransitionHash) + } + forward := adapterRequestABI{ + Verifiers: request.Verifiers, + ProfileHashes: profiles, + EvidenceHashes: evidences, + TransitionHashes: transitions, + CurrentProfileHash: request.CurrentProfile, + CurrentTransitionHash: request.CurrentTransition, + Options: c.options, + } + stage := dispatchStage(request.HopIndex, request.Protocol) + fee, err := dispatchFee(c.chains.store, actionID(request.AttemptID, stage), func() (*big.Int, error) { + quoted, err := adapter.call(ctx, "quoteForward", forward) + if err != nil { + return nil, err + } + return nativeFee(quoted) + }) + if err != nil { + return DispatchResult{}, fmt.Errorf("runner: layerzero dispatch fee: %w", err) + } + + name := "sendSource" + if request.HopIndex > 1 { + name = "forwardInFlight" + } + result, err := adapter.send(ctx, request.AttemptID, stage, name, []any{forward}, fee) + if err != nil { + return DispatchResult{}, err + } + events, err := adapter.events(ctx, result, "VerifiedEvidenceForwarded") + if err != nil { + return DispatchResult{}, err + } + if len(events) != 1 { + return DispatchResult{}, fmt.Errorf( + "runner: hop %d dispatch emitted %d VerifiedEvidenceForwarded events", request.HopIndex, len(events), + ) + } + guid, ok := events[0]["guid"].([32]byte) + if !ok { + return DispatchResult{}, fmt.Errorf("runner: VerifiedEvidenceForwarded guid is missing") + } + packet, err := c.readPacket(ctx, adapter, request, result) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{ + Evidence: Evidence{ + Hash: guid, + NativeMessageID: "0x" + common.Bytes2Hex(guid[:]), + Detail: map[string]any{ + "protocol": "L", + "hop_index": request.HopIndex, + "native_message_id": "0x" + common.Bytes2Hex(guid[:]), + "quote_native_fee_wei": fee.String(), + "destination_eid": c.eids[request.DestinationRole], + "packet_nonce": packet.Nonce, + }, + }, + Stage: stage, + Transaction: result.TransactionHash, + delivery: layerZeroDelivery{ + Packet: packet, + SourceResult: result, + SourceRole: request.SourceRole, + }, + }, nil +} + +// readPacket extracts the PacketSent payload of this dispatch from the source +// endpoint logs and decodes it. +func (c *layerZeroCarrier) readPacket( + ctx context.Context, + adapter *bound, + request HopRequest, + result state.ActionResult, +) (layerzero.Packet, error) { + endpoint, err := c.chains.deployment.Infra(request.SourceRole, "endpoint_v2") + if err != nil { + return layerzero.Packet{}, err + } + receipt, err := adapter.client.Receipt(ctx, common.HexToHash(result.TransactionHash)) + if err != nil { + return layerzero.Packet{}, err + } + packet, err := layerzero.ExtractPacket(receipt, c.eids[request.SourceRole]) + if err != nil { + return layerzero.Packet{}, fmt.Errorf("runner: hop %d dispatch: %w", request.HopIndex, err) + } + _ = endpoint + return packet, nil +} + +// Confirm runs the self-hosted DVN and Executor stages when this runtime owns +// them, then waits for the destination adapter to verify the bundle. +func (c *layerZeroCarrier) Confirm(ctx context.Context, request HopRequest, dispatched DispatchResult) error { + delivery, ok := dispatched.delivery.(layerZeroDelivery) + if !ok { + return fmt.Errorf("runner: layerzero dispatch result is missing delivery data") + } + if c.embedded { + if err := c.deliver(ctx, request, delivery); err != nil { + return err + } + } + return awaitVerified(ctx, c.chains, request, dispatched.Evidence, c.poll, c.timeout) +} + +// deliver submits the official DVN, ReceiveUln302, and Executor transactions. +func (c *layerZeroCarrier) deliver(ctx context.Context, request HopRequest, delivery layerZeroDelivery) error { + destination, err := c.chains.chain(request.DestinationRole) + if err != nil { + return err + } + dvn, err := c.chains.deployment.Infra(request.DestinationRole, "dvn") + if err != nil { + return err + } + receiveULN, err := c.chains.deployment.Infra(request.DestinationRole, "receive_uln_302") + if err != nil { + return err + } + executor, err := c.chains.deployment.Infra(request.DestinationRole, "executor") + if err != nil { + return err + } + transactor, err := destination.agentTransactor(c.workerKey) + if err != nil { + return err + } + workerSigner, err := evm.NewSigner(c.workerKey, new(big.Int).SetUint64(destination.config.ChainID)) + if err != nil { + return fmt.Errorf("runner: layerzero worker signer: %w", err) + } + expiration := big.NewInt(time.Now().Add(time.Hour).Unix()) + frozen, err := c.chains.store.Action(actionID(request.AttemptID, layerZeroStage(request.HopIndex, layerzero.StageDVNExecute))) + if err != nil { + return err + } + if frozen != nil { + if err := state.VerifyActionIdentity(frozen); err != nil { + return err + } + data, err := abiutil.DecodeHex(frozen.CalldataHex) + if err != nil { + return err + } + expiration, err = layerzero.DVNExpiration(data) + if err != nil { + return fmt.Errorf("runner: frozen DVN expiration: %w", err) + } + } + + actions, err := layerzero.Plan(delivery.Packet, layerzero.PlanConfig{ + DVN: dvn, + ReceiveULN: receiveULN, + Executor: executor, + Confirmations: c.confirmations, + GasLimit: c.gasLimit, + TransactionGas: c.transactionGas, + Expiration: expiration, + }, workerSigner) + if err != nil { + return fmt.Errorf("runner: layerzero delivery plan: %w", err) + } + for _, action := range actions { + stage := layerZeroStage(request.HopIndex, action.Stage) + result, err := transactor.Execute(ctx, evm.TxRequest{ + ActionID: actionID(request.AttemptID, stage), + AttemptID: request.AttemptID, + Stage: stage, + ChainRole: request.DestinationRole, + To: action.Target, + Data: action.CallData, + Gas: action.Gas, + }) + if err != nil { + return fmt.Errorf("runner: layerzero %s: %w", action.Stage, err) + } + if c.faultAfterStage == stage { + return fmt.Errorf("runner: injected fault after stage %s", stage) + } + if err := verifyStageTopic(ctx, destination.client, result, action.ExpectedTopic, action.Stage); err != nil { + return err + } + row, err := eventRow( + request.AttemptID, stage, "succeeded", "embedded-worker", + request.DestinationRole, request.HopIndex, result.TransactionHash, + map[string]any{ + "guid": xir.FormatDigest(delivery.Packet.GUID), + "target": action.Target.Hex(), + "stage": action.Stage, + "nonce": delivery.Packet.Nonce, + "gas_used": result.GasUsed, + }, + ) + if err != nil { + return err + } + if err := c.chains.recordEvent(row); err != nil { + return err + } + } + return nil +} + +// awaitVerified polls the destination adapter until every receipt of the +// current bundle is accepted. +func awaitVerified( + ctx context.Context, + chains *contractSet, + request HopRequest, + evidence Evidence, + poll time.Duration, + timeout time.Duration, +) error { + bundle := bundleWithCurrent(request, evidence) + adapter := request.DestinationAdapter + deadline := time.Now().Add(timeout) + for { + verified, err := bundleVerified(ctx, adapter, bundle) + if err != nil { + return err + } + if verified { + row, err := eventRow( + request.AttemptID, callbackStage(request.HopIndex, request.Protocol), + "accepted", "coordinator_read", request.DestinationRole, request.HopIndex, "", + map[string]any{ + "adapter": adapter.address.Hex(), + "verified_tuple_count": len(bundle), + "profile_hash": xir.FormatDigest(request.CurrentProfile), + "evidence_hash": xir.FormatDigest(evidence.Hash), + }, + ) + if err != nil { + return err + } + return chains.recordEvent(row) + } + if time.Now().After(deadline) { + return fmt.Errorf( + "runner: timed out waiting for hop %d bundle at %s:%s", + request.HopIndex, adapter.role, adapter.key, + ) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(poll): + } + } +} + +func bundleVerified(ctx context.Context, adapter *bound, bundle []xir.Receipt) (bool, error) { + for _, receipt := range bundle { + values, err := adapter.call( + ctx, "verify", receipt.ProfileHash, receipt.EvidenceHash, receipt.TransitionHash, + ) + if err != nil { + return false, err + } + if len(values) != 1 { + return false, fmt.Errorf("runner: adapter verify returned %d values", len(values)) + } + accepted, ok := values[0].(bool) + if !ok { + return false, fmt.Errorf("runner: adapter verify returned %T", values[0]) + } + if !accepted { + return false, nil + } + } + return true, nil +} diff --git a/go-runtime/internal/runner/ledger.go b/go-runtime/internal/runner/ledger.go new file mode 100644 index 0000000..df3b6f8 --- /dev/null +++ b/go-runtime/internal/runner/ledger.go @@ -0,0 +1,59 @@ +package runner + +import ( + "github.com/lysrain21/XIR/go-runtime/internal/state" +) + +// The ledger helpers keep every durable row in the same shape the Python +// runtime writes: details are canonical JSON with sorted keys, and stage rows +// always carry the chain role and hop index their events carry. + +func stageRow( + attemptID string, + stage string, + transactionHash string, + detail map[string]any, +) (state.StageRow, error) { + document, err := state.CanonicalJSON(detail) + if err != nil { + return state.StageRow{}, err + } + row := state.StageRow{ + AttemptID: attemptID, + Stage: stage, + State: "succeeded", + DetailJSON: document, + } + if transactionHash != "" { + hash := transactionHash + row.TransactionHash = &hash + } + return row, nil +} + +func eventRow( + attemptID string, + stage string, + event string, + source string, + chainRole string, + hopIndex int, + transactionHash string, + detail map[string]any, +) (state.EventRow, error) { + document, err := state.CanonicalJSON(detail) + if err != nil { + return state.EventRow{}, err + } + index := hopIndex + return state.EventRow{ + AttemptID: attemptID, + Stage: stage, + Event: event, + Source: source, + ChainRole: chainRole, + HopIndex: &index, + TransactionHash: transactionHash, + DetailJSON: document, + }, nil +} diff --git a/go-runtime/internal/runner/protocol.go b/go-runtime/internal/runner/protocol.go new file mode 100644 index 0000000..2d9e02e --- /dev/null +++ b/go-runtime/internal/runner/protocol.go @@ -0,0 +1,207 @@ +package runner + +import ( + "context" + "encoding/binary" + "fmt" + "math/big" + "path/filepath" + "sort" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/lysrain21/XIR/go-runtime/internal/artifacts" + "github.com/lysrain21/XIR/go-runtime/internal/evm" + "github.com/lysrain21/XIR/go-runtime/internal/hyperlane" + "github.com/lysrain21/XIR/go-runtime/internal/state" + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// loadProtocol loads one contract of a pinned protocol stack. root is the +// protocol-projects directory that holds /out. +func loadProtocol(root, contractName string) (artifacts.Artifact, error) { + stack := "hyperlane-native" + switch contractName { + case "EndpointV2", "SendUln302", "ReceiveUln302", "DVN", "Executor", "PriceFeed", "Treasury", "DVNFeeLib", "ExecutorFeeLib", "ERC1967Proxy": + stack = "layerzero-native" + } + artifact, err := artifacts.Load(filepath.Join(root, stack, "out"), contractName) + if err != nil { + return artifacts.Artifact{}, fmt.Errorf("runner: load protocol contract %s: %w", contractName, err) + } + return artifact, nil +} + +// checkpoint is the origin tree checkpoint one dispatched message belongs to. +type checkpoint struct { + Root [32]byte + Index uint32 +} + +// readCheckpoint reads the origin Merkle tree hook checkpoint for the message +// that was inserted at the recorded index. The hook address comes from the +// InsertedIntoTree log of the dispatch receipt, not from an assumption about +// which mailbox hook maintains the tree. +func readCheckpoint( + ctx context.Context, + protocolRoot string, + source *roleChain, + delivery hyperlaneDelivery, +) (checkpoint, error) { + hook := &bound{ + role: delivery.SourceRole, + key: "hyperlane_tree_hook", + address: delivery.OriginHook, + client: source.client, + transactor: source.transactor, + gasLimit: source.transactorGasLimit(), + } + artifact, err := loadProtocol(protocolRoot, "MerkleTreeHook") + if err != nil { + return checkpoint{}, err + } + hook.artifact = artifact + receipt, err := source.client.Receipt(ctx, common.HexToHash(delivery.SourceResult.TransactionHash)) + if err != nil { + return checkpoint{}, err + } + if receipt == nil || receipt.Status != types.ReceiptStatusSuccessful { + return checkpoint{}, fmt.Errorf("runner: dispatch has no successful receipt") + } + header, err := source.client.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return checkpoint{}, err + } + if header == nil || header.Hash() != receipt.BlockHash { + return checkpoint{}, fmt.Errorf("runner: dispatch block is no longer canonical") + } + data, err := hook.artifact.ABI.PackCall("latestCheckpoint") + if err != nil { + return checkpoint{}, err + } + output, err := source.client.CallContractAtHash(ctx, hook.address, data, receipt.BlockHash) + if err != nil { + return checkpoint{}, err + } + values, err := hook.artifact.ABI.ABI().Methods["latestCheckpoint"].Outputs.Unpack(output) + if err != nil { + return checkpoint{}, err + } + if len(values) != 2 { + return checkpoint{}, fmt.Errorf("runner: latestCheckpoint returned %d values", len(values)) + } + root, ok := values[0].([32]byte) + if !ok { + return checkpoint{}, fmt.Errorf("runner: latestCheckpoint root is %T", values[0]) + } + index, ok := values[1].(uint32) + if !ok { + return checkpoint{}, fmt.Errorf("runner: latestCheckpoint index is %T", values[1]) + } + if index < delivery.InsertedIndex { + return checkpoint{}, fmt.Errorf("runner: checkpoint precedes dispatch") + } + if index > delivery.InsertedIndex { + // Several messages may be inserted in the same block. Historical calls + // expose end-of-block state, so replay the authenticated hook's leaves + // only up to this transaction's insertion rather than sign a later root. + logs, err := source.client.Logs(ctx, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: receipt.BlockNumber, + Addresses: []common.Address{delivery.OriginHook}, + Topics: [][]common.Hash{{hyperlane.InsertedIntoTreeTopic}}, + }) + if err != nil { + return checkpoint{}, err + } + return checkpointAtInsertion(logs, delivery, receipt.BlockHash) + } + + return checkpoint{Root: root, Index: index}, nil +} + +// verifyStageTopic fails when a submitted stage receipt lacks its official +// success event, mirroring the LayerZero worker's post-transaction check. +func verifyStageTopic( + ctx context.Context, + client *evm.Client, + result state.ActionResult, + topic common.Hash, + stage string, +) error { + if topic == (common.Hash{}) { + return nil + } + receipt, err := client.Receipt(ctx, common.HexToHash(result.TransactionHash)) + if err != nil { + return err + } + for _, log := range receipt.Logs { + if len(log.Topics) > 0 && log.Topics[0] == topic { + return nil + } + } + return fmt.Errorf("runner: %s stage lacks its official success event %s", stage, topic.Hex()) +} + +// insertedIntoTree decodes the InsertedIntoTree(bytes32,uint32) log of the +// origin Merkle tree hook. The data is two ABI words, so it is decoded +// directly rather than through an artifact lookup. +func insertedIntoTree(log *types.Log) (messageID [32]byte, index uint32, ok bool) { + if len(log.Data) != 64 { + return messageID, 0, false + } + copy(messageID[:], log.Data[:32]) + index = binary.BigEndian.Uint32(log.Data[60:64]) + return messageID, index, true +} + +// checkpointAtInsertion reproduces the pinned IncrementalMerkle tree root. +func checkpointAtInsertion(logs []types.Log, delivery hyperlaneDelivery, blockHash common.Hash) (checkpoint, error) { + sort.Slice(logs, func(i, j int) bool { + if logs[i].BlockNumber != logs[j].BlockNumber { + return logs[i].BlockNumber < logs[j].BlockNumber + } + return logs[i].Index < logs[j].Index + }) + var branch [32][32]byte + var count uint64 + for i := range logs { + log := &logs[i] + if log.Removed || log.Address != delivery.OriginHook || len(log.Topics) == 0 || log.Topics[0] != hyperlane.InsertedIntoTreeTopic { + continue + } + leaf, index, ok := insertedIntoTree(log) + if !ok || uint64(index) != count { + return checkpoint{}, fmt.Errorf("runner: non-contiguous Merkle hook history at index %d", count) + } + count++ + node, size := leaf, count + for height := 0; height < 32; height++ { + if size&1 == 1 { + branch[height] = node + break + } + node = xir.Keccak256(branch[height][:], node[:]) + size >>= 1 + } + if index != delivery.InsertedIndex { + continue + } + if leaf != delivery.MessageID || log.TxHash != common.HexToHash(delivery.SourceResult.TransactionHash) || log.BlockHash != blockHash { + return checkpoint{}, fmt.Errorf("runner: Merkle insertion does not match dispatch identity") + } + var root, zero [32]byte + for height := 0; height < 32; height++ { + if count&(uint64(1)< 0 { + return summary, fmt.Errorf("runner: %d of %d attempts failed", len(summary.Failures), summary.Attempts) + } + return summary, nil +} + +// runAttempt performs one route execution end to end. +func (r *Runner) runAttempt(ctx context.Context, attempt Attempt) error { + if attempt.Route == "" || attempt.RouteSequence > ^uint64(0)>>1 { + return fmt.Errorf("runner: attempt %s has an invalid route", attempt.AttemptID) + } + if len(attempt.Route)+1 > len(roles) { + return fmt.Errorf("runner: route %q exceeds the five-chain topology", attempt.Route) + } + coordinates, err := state.CanonicalJSON(map[string]any{ + "route": attempt.Route, + "route_sequence": attempt.RouteSequence, + "switch_count": attempt.SwitchCount, + "hop_count": len(attempt.Route), + }) + if err != nil { + return err + } + first, err := r.state.BeginAttempt(state.AttemptRow{ + AttemptID: attempt.AttemptID, + Phase: attempt.Phase, + Route: attempt.Route, + RouteSequence: int(attempt.RouteSequence), + CoordinatesJSON: coordinates, + Status: "running", + }) + if err != nil { + return err + } + if !first { + return nil + } + r.chains.SetRoute(attempt.Route) + payload, err := r.applicationPayload(attempt) + if err != nil { + return err + } + record, context, rid, signature, err := r.createRoot(ctx, attempt, payload) + if err != nil { + return err + } + receipts, err := r.walkHops(ctx, attempt, payload, record, context, signature, rid) + if err != nil { + return err + } + if err := r.deliver(ctx, attempt, payload, record, context, signature, receipts); err != nil { + return err + } + if err := r.state.FinishAttempt(attempt.AttemptID); err != nil { + return err + } + completed, err := eventRow( + attempt.AttemptID, StageDestinationDelivery, "completed", "coordinator", + roles[len(attempt.Route)], len(attempt.Route), "", + map[string]any{ + "rid": xir.FormatDigest(rid), + "receipt_count": len(receipts), + "route": attempt.Route, + }, + ) + if err != nil { + return err + } + return r.state.RecordEvent(completed) +} + +// applicationPayload rebuilds the frozen application payload of one attempt. +func (r *Runner) applicationPayload(attempt Attempt) ([]byte, error) { + application, err := xir.ApplicationBytes( + r.config.FixedSeed, attempt.Phase, attempt.RouteSequence, r.config.PayloadSchedule, + ) + if err != nil { + return nil, err + } + if attempt.PayloadSHA256 != "" { + digest := sha256.Sum256(application) + if hex.EncodeToString(digest[:]) != strings.ToLower(strings.TrimPrefix(attempt.PayloadSHA256, "0x")) { + return nil, fmt.Errorf("runner: attempt %s payload differs from the frozen plan", attempt.AttemptID) + } + } + attemptKey := xir.Keccak256([]byte(attempt.AttemptID)) + payload, err := abiutil.PackArgument(payloadArgumentJSON, xir.ApplicationPayload( + attemptKey, attempt.Route, attempt.RouteSequence, application, + )) + if err != nil { + return nil, fmt.Errorf("runner: encode application payload: %w", err) + } + return payload, nil +} + +const payloadArgumentJSON = `{"name":"payload","type":"tuple","components":[` + + `{"name":"attemptId","type":"bytes32"},{"name":"route","type":"bytes"},` + + `{"name":"routeSequence","type":"uint64"},{"name":"applicationPayload","type":"bytes"}]}` + +// createRoot creates the XIR record on the source chain and returns the signed +// certificate that every hop carries. +func (r *Runner) createRoot( + ctx context.Context, + attempt Attempt, + payload []byte, +) (xir.Record, xir.Context, [32]byte, []byte, error) { + sourceRole := roles[0] + destinationRole := roles[len(attempt.Route)] + gateway, err := r.chains.bind(sourceRole, "gateway") + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + receiver, err := r.chains.bind(destinationRole, "receiver") + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + runnerSigner, err := r.signerFor(sourceRole) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + gatewayID, err := xir.GatewayTypedID(r.chainConfig[sourceRole].ChainID) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + sourceApp, err := xir.NewEVMID(runnerSigner.Address().Bytes()) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + destinationApp, err := xir.NewEVMID(receiver.address.Bytes()) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + nonce, err := r.rootNonce(ctx, attempt, gateway, runnerSigner.Address()) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + record := xir.Record{ + SourceGateway: gatewayID, + SourceApp: sourceApp, + DestinationApp: destinationApp, + Nonce: nonce, + PayloadHash: xir.Keccak256(payload), + } + context := xir.Context{RequiredSecurity: 1, PolicyHash: xir.Keccak256([]byte(r.config.PolicyLabel))} + rid, err := xir.RootID(record, context, xir.RegistryVersion) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + rootIntent := map[string]any{"record_nonce": record.Nonce} + // Legacy actions did not freeze caller metadata; keep their intent intact. + existing, err := r.state.Action(actionID(attempt.AttemptID, StageRootCreate)) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if existing != nil { + detail, err := existing.Detail() + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if _, ok := detail["intent_detail"]; !ok { + rootIntent = nil + } + } + result, err := gateway.sendWithIntent( + ctx, + attempt.AttemptID, + StageRootCreate, + "createRecord", + []any{record.DestinationApp.ABI(), payload, context.ABI(), xir.RegistryVersion}, + nil, + rootIntent, + ) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if err := r.verifyRootCreation(ctx, gateway, attempt, record, context, rid, result); err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + rootStage, err := stageRow(attempt.AttemptID, StageRootCreate, result.TransactionHash, map[string]any{ + "record_nonce": record.Nonce, + "record_payload_hash": xir.FormatDigest(record.PayloadHash), + "gateway": gateway.address.Hex(), + "rid": xir.FormatDigest(rid), + }) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if err := r.state.RecordStage(rootStage); err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if err := r.fault(StageRootCreate); err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + rootSigner, err := r.rootSignerFor(sourceRole) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + signature, err := rootSigner.SignPersonalDigest(rid) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + row, err := eventRow( + attempt.AttemptID, "root_certificate_ready", "ready", "root_signer", + sourceRole, 0, result.TransactionHash, + map[string]any{ + "rid": xir.FormatDigest(rid), + "root_signer": rootSigner.Address().Hex(), + "root_transaction_hash": result.TransactionHash, + "record_nonce": record.Nonce, + }, + ) + if err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + if err := r.state.RecordEvent(row); err != nil { + return xir.Record{}, xir.Context{}, [32]byte{}, nil, err + } + return record, context, rid, signature, nil +} + +// verifyRootCreation checks the mined record against the finalized creation the +// Python root signer reconstructs before it signs. +func (r *Runner) verifyRootCreation( + ctx context.Context, + gateway *bound, + attempt Attempt, + record xir.Record, + context xir.Context, + rid [32]byte, + result state.ActionResult, +) error { + receipt, err := gateway.client.Receipt(ctx, common.HexToHash(result.TransactionHash)) + if err != nil { + return err + } + if receipt.Status != 1 { + return fmt.Errorf("runner: root creation transaction failed") + } + if _, err := r.awaitFinality(ctx, gateway.client, receipt); err != nil { + return err + } + events, err := gateway.events(ctx, result, "RootCreated") + if err != nil { + return err + } + if len(events) != 1 { + return fmt.Errorf("runner: root creation emitted %d RootCreated events", len(events)) + } + observed, ok := events[0]["rid"].([32]byte) + if !ok || observed != rid { + return fmt.Errorf("runner: RootCreated rid does not match the reconstructed root") + } + expectedMid, err := xir.MessageID(rid, record.DestinationApp) + if err != nil { + return err + } + observedMid, ok := events[0]["mid"].([32]byte) + if !ok || observedMid != expectedMid { + return fmt.Errorf("runner: RootCreated mid does not match the reconstructed message id") + } + if nonce, err := asUint64(events[0]["nonce"]); err != nil || nonce != record.Nonce { + return fmt.Errorf("runner: RootCreated nonce does not match the reserved nonce") + } + if sender, ok := events[0]["sender"].(common.Address); !ok || sender != r.mustRunnerAddress(roles[0]) { + return fmt.Errorf("runner: RootCreated sender is not the configured runner") + } + // The signed bytes live in the durable spool; decoding them there proves the + // transaction the runtime froze is the one the chain accepted. + raw, err := os.ReadFile(spoolPath(gateway, result.TransactionHash)) + if err != nil { + return fmt.Errorf("runner: read the frozen root creation transaction: %w", err) + } + transaction := &types.Transaction{} + if err := transaction.UnmarshalBinary(raw); err != nil { + return fmt.Errorf("runner: decode the frozen root creation transaction: %w", err) + } + if to := transaction.To(); to == nil || *to != gateway.address { + return fmt.Errorf("runner: root creation transaction targets another contract") + } + document := gateway.artifact.ABI.ABI() + method, err := document.MethodById(transaction.Data()[:4]) + if err != nil { + return fmt.Errorf("runner: decode root creation transaction: %w", err) + } + if method.RawName != "createRecord" { + return fmt.Errorf("runner: root creation transaction is not createRecord") + } + arguments, err := method.Inputs.Unpack(transaction.Data()[4:]) + if err != nil { + return fmt.Errorf("runner: decode root creation arguments: %w", err) + } + if len(arguments) != 4 { + return fmt.Errorf("runner: createRecord has %d arguments", len(arguments)) + } + observedPayload, ok := arguments[1].([]byte) + if !ok || xir.Keccak256(observedPayload) != record.PayloadHash { + return fmt.Errorf("runner: root creation payload hash differs from the record") + } + observedContext, ok := abi.ConvertType(arguments[2], xir.ContextABI{}).(xir.ContextABI) + if !ok { + return fmt.Errorf("runner: root creation context has an unexpected shape") + } + if observedContext.RequiredSecurity != context.RequiredSecurity || observedContext.PolicyHash != context.PolicyHash { + return fmt.Errorf("runner: root creation context differs from the record context") + } + version, ok := arguments[3].(uint32) + if !ok || version != xir.RegistryVersion { + return fmt.Errorf("runner: root creation registry version differs") + } + _ = attempt + return nil +} + +// awaitFinality applies the configured confirmation rule to one mined +// receipt and returns the rule that accepted it. +func (r *Runner) awaitFinality(ctx context.Context, client *evm.Client, receipt *types.Receipt) (string, error) { + blockNumber := receipt.BlockNumber.Uint64() + blockHash := receipt.BlockHash + timeout := r.config.Finality.Timeout + if timeout <= 0 { + timeout = defaultFinalityTimeout + } + switch r.config.Finality.Mode { + case "", FinalityRPCFinalized: + deadline := time.Now().Add(timeout) + for { + finalized, rule, err := client.FinalizedNumberFor(ctx, blockNumber, blockHash) + if err != nil { + return "", err + } + if blockNumber <= finalized { + return rule, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf( + "runner: block %d has not reached the finalized height %d under %s", + blockNumber, finalized, rule, + ) + } + if err := sleepContext(ctx, finalizedPollInterval); err != nil { + return "", err + } + } + case FinalityConfirmations: + required := blockNumber + r.config.Finality.Confirmations + deadline := time.Now().Add(timeout) + for { + head, err := client.BlockNumber(ctx) + if err != nil { + return "", err + } + if head >= required { + canonical, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(blockNumber)) + if err != nil { + return "", err + } + if canonical == nil || canonical.Hash() != blockHash { + return "", fmt.Errorf("runner: block %d left the canonical chain", blockNumber) + } + return fmt.Sprintf("confirmations-%d", r.config.Finality.Confirmations), nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf( + "runner: block %d has %d successors of %d required", + blockNumber, head-blockNumber, r.config.Finality.Confirmations, + ) + } + if err := sleepContext(ctx, finalizedPollInterval); err != nil { + return "", err + } + } + case FinalityNone: + return "none-test-only", nil + default: + return "", fmt.Errorf("runner: unsupported finality mode %q", r.config.Finality.Mode) + } +} + +// sleepContext waits for one poll interval or until the context ends. +func sleepContext(ctx context.Context, interval time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + return nil + } +} + +// rootNonce returns the gateway record nonce of one attempt. A resumed attempt +// reuses its pre-signing intent nonce, or recovers legacy roots from their +// receipt/stage. A fresh attempt takes max(nextNonce, next reserved nonce) +// so another durable intent cannot reuse the same record nonce. +func (r *Runner) rootNonce(ctx context.Context, attempt Attempt, gateway *bound, runner common.Address) (uint64, error) { + action, err := r.state.Action(actionID(attempt.AttemptID, StageRootCreate)) + if err != nil { + return 0, err + } + if action != nil { + if err := state.VerifyActionIdentity(action); err != nil { + return 0, err + } + detail, err := action.Detail() + if err != nil { + return 0, err + } + if frozen, ok := detail["intent_detail"]; ok { + encoded, err := state.CanonicalJSON(frozen) + if err != nil { + return 0, err + } + if nonce, ok := recordNonceFromDetail(encoded); ok { + return nonce, nil + } + return 0, fmt.Errorf("runner: root action has invalid frozen record nonce") + } + // Upgrade recovery for roots mined by the previous runtime, whose + // current stage may lack record_nonce after a finality timeout. + if action.TransactionHash != "" { + receipt, err := gateway.client.Receipt(ctx, common.HexToHash(action.TransactionHash)) + if err != nil { + return 0, err + } + if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { + events, err := gateway.events(ctx, state.ActionResult{TransactionHash: action.TransactionHash}, "RootCreated") + if err != nil { + return 0, err + } + if len(events) != 1 { + return 0, fmt.Errorf("runner: existing root has %d creation events", len(events)) + } + return asUint64(events[0]["nonce"]) + } + } + } + stage, err := r.state.Stage(attempt.AttemptID, StageRootCreate) + if err != nil { + return 0, err + } + if stage != nil && stage.DetailJSON != "" && stage.DetailJSON != "{}" { + if recorded, ok := recordNonceFromDetail(stage.DetailJSON); ok { + return recorded, nil + } + } + values, err := gateway.call(ctx, "nextNonce", runner) + if err != nil { + return 0, err + } + if len(values) != 1 { + return 0, fmt.Errorf("runner: nextNonce returned %d values", len(values)) + } + chainNonce, err := asUint64(values[0]) + if err != nil { + return 0, fmt.Errorf("runner: nextNonce: %w", err) + } + reserved, err := r.state.NextReservedRootNonce() + if err != nil { + return 0, err + } + if chainNonce > reserved { + return chainNonce, nil + } + return reserved, nil +} + +// asUint64 accepts the integer shapes the ABI codec produces for uint64 and +// uint256 outputs. +func asUint64(value any) (uint64, error) { + switch typed := value.(type) { + case uint64: + return typed, nil + case uint32: + return uint64(typed), nil + case uint16: + return uint64(typed), nil + case uint8: + return uint64(typed), nil + case *big.Int: + if typed.Sign() < 0 || !typed.IsUint64() { + return 0, fmt.Errorf("value %s is not a uint64", typed) + } + return typed.Uint64(), nil + default: + return 0, fmt.Errorf("value %T is not an integer", value) + } +} + +// recordNonceFromDetail reads the frozen record nonce of a root stage row. +func recordNonceFromDetail(document string) (uint64, bool) { + var detail struct { + RecordNonce *uint64 `json:"record_nonce"` + } + if err := json.Unmarshal([]byte(document), &detail); err != nil { + return 0, false + } + if detail.RecordNonce == nil { + return 0, false + } + return *detail.RecordNonce, true +} + +// fault aborts the attempt when the caller injected a fault at this stage. +func (r *Runner) fault(stage string) error { + if r.config.FaultAfterStage != "" && r.config.FaultAfterStage == stage { + return fmt.Errorf("runner: injected fault after stage %s", stage) + } + return nil +} + +// walkHops dispatches and confirms every hop, recording the carrier switch. +func (r *Runner) walkHops( + ctx context.Context, + attempt Attempt, + payload []byte, + record xir.Record, + context xir.Context, + signature []byte, + rid [32]byte, +) ([]xir.Receipt, error) { + receipts := make([]xir.Receipt, 0, len(attempt.Route)) + prefix := xir.RootPrefix(rid) + for index := 0; index < len(attempt.Route); index++ { + hopIndex := index + 1 + protocol := attempt.Route[index : index+1] + sourceRole := roles[index] + destinationRole := roles[index+1] + profile, err := xir.ProfileHash(attempt.Route, hopIndex) + if err != nil { + return nil, err + } + sourceGatewayID, err := xir.GatewayTypedID(r.chainConfig[sourceRole].ChainID) + if err != nil { + return nil, err + } + destinationGatewayID, err := xir.GatewayTypedID(r.chainConfig[destinationRole].ChainID) + if err != nil { + return nil, err + } + transition, err := xir.TransitionHash(record, context, sourceGatewayID, destinationGatewayID) + if err != nil { + return nil, err + } + sourceAdapter, err := r.chains.bind(sourceRole, mustAdapterKey(attempt.Route, hopIndex, "out")) + if err != nil { + return nil, err + } + destinationAdapter, err := r.chains.bind(destinationRole, mustAdapterKey(attempt.Route, hopIndex, "in")) + if err != nil { + return nil, err + } + verifiers, err := r.verifiers(attempt, hopIndex, sourceRole, len(receipts)) + if err != nil { + return nil, err + } + request := HopRequest{ + Route: attempt.Route, + AttemptID: attempt.AttemptID, + HopIndex: hopIndex, + Protocol: protocol, + SourceRole: sourceRole, + DestinationRole: destinationRole, + SourceAdapter: sourceAdapter, + DestinationAdapter: destinationAdapter, + Verifiers: verifiers, + PriorReceipts: receipts, + CurrentProfile: profile, + CurrentTransition: transition, + } + carrier, ok := r.carriers[protocol] + if !ok { + return nil, fmt.Errorf("runner: unsupported carrier %q", protocol) + } + dispatched, err := carrier.Dispatch(ctx, request) + if err != nil { + return nil, err + } + stage, err := stageRow(attempt.AttemptID, dispatched.Stage, dispatched.Transaction, dispatched.Evidence.Detail) + if err != nil { + return nil, err + } + if err := r.state.RecordStage(stage); err != nil { + return nil, err + } + if err := r.fault(dispatched.Stage); err != nil { + return nil, err + } + if err := carrier.Confirm(ctx, request, dispatched); err != nil { + return nil, err + } + receipts = append(receipts, xir.Receipt{ + SourceGateway: sourceGatewayID, + DestinationGateway: destinationGatewayID, + ProfileHash: profile, + EvidenceHash: dispatched.Evidence.Hash, + TransitionHash: transition, + PriorPrefix: prefix, + }) + next, err := xir.NextPrefix(prefix, receipts[len(receipts)-1]) + if err != nil { + return nil, err + } + prefix = next + if hopIndex < len(attempt.Route) && attempt.Route[hopIndex:hopIndex+1] != protocol { + if err := r.recordTransition(ctx, attempt, payload, record, context, signature, receipts, hopIndex); err != nil { + return nil, err + } + } + } + return receipts, nil +} + +// verifiers returns the approved prior verifier list of one hop: the inbound +// adapter of the previous hop, repeated once per prior receipt. +func (r *Runner) verifiers( + attempt Attempt, + hopIndex int, + sourceRole string, + priorCount int, +) ([]common.Address, error) { + if hopIndex == 1 || priorCount == 0 { + return []common.Address{}, nil + } + previous, err := r.chains.bind(sourceRole, mustAdapterKey(attempt.Route, hopIndex-1, "in")) + if err != nil { + return nil, err + } + verifiers := make([]common.Address, 0, priorCount) + for index := 0; index < priorCount; index++ { + verifiers = append(verifiers, previous.address) + } + return verifiers, nil +} + +// recordTransition records a carrier switch on the intermediate chain. +func (r *Runner) recordTransition( + ctx context.Context, + attempt Attempt, + payload []byte, + record xir.Record, + context xir.Context, + signature []byte, + receipts []xir.Receipt, + hopIndex int, +) error { + role := roles[hopIndex] + recorder, err := r.chains.bind(role, "transition_recorder") + if err != nil { + return err + } + nextProfile, err := xir.ProfileHash(attempt.Route, hopIndex+1) + if err != nil { + return err + } + envelope := xir.Envelope{ + Record: record, + Context: context, + RegistryVersion: xir.RegistryVersion, + Signature: signature, + Receipts: receipts, + } + stage := transitionStage(hopIndex + 1) + result, err := recorder.send( + ctx, attempt.AttemptID, stage, "record", + []any{payload, envelope.ABI(), uint8(hopIndex), nextProfile}, + nil, + ) + if err != nil { + return err + } + if err := r.fault(stage); err != nil { + return err + } + events, err := recorder.events(ctx, result, "NativeMultihopTransitionRecorded") + if err != nil { + return err + } + if len(events) != 1 { + return fmt.Errorf("runner: switch at hop %d emitted %d transition events", hopIndex, len(events)) + } + observed, ok := events[0]["outboundProfileHash"].([32]byte) + if !ok || observed != nextProfile { + return fmt.Errorf("runner: recorded transition does not carry the outbound profile") + } + return nil +} + +// deliver submits the envelope to the destination gateway and verifies the +// application effect. +func (r *Runner) deliver( + ctx context.Context, + attempt Attempt, + payload []byte, + record xir.Record, + context xir.Context, + signature []byte, + receipts []xir.Receipt, +) error { + destinationRole := roles[len(attempt.Route)] + receiver, err := r.chains.bind(destinationRole, "receiver") + if err != nil { + return err + } + gateway, err := r.chains.bind(destinationRole, "gateway") + if err != nil { + return err + } + envelope := xir.Envelope{ + Record: record, + Context: context, + RegistryVersion: xir.RegistryVersion, + Signature: signature, + Receipts: receipts, + } + result, err := gateway.send( + ctx, attempt.AttemptID, StageDestinationDelivery, "deliver", + []any{envelope.ABI(), payload, receiver.address}, + nil, + ) + if err != nil { + return err + } + if err := r.fault(StageDestinationDelivery); err != nil { + return err + } + events, err := receiver.events(ctx, result, "NativeMultihopEffectApplied") + if err != nil { + return err + } + if len(events) != 1 { + return fmt.Errorf("runner: delivery emitted %d effect events", len(events)) + } + attemptKey := xir.Keccak256([]byte(attempt.AttemptID)) + if observed, ok := events[0]["attemptId"].([32]byte); !ok || observed != attemptKey { + return fmt.Errorf("runner: effect attempt id differs from the attempt") + } + rid, err := xir.RootID(record, context, xir.RegistryVersion) + if err != nil { + return err + } + expectedMid, err := xir.MessageID(rid, record.DestinationApp) + if err != nil { + return err + } + if observed, ok := events[0]["messageId"].([32]byte); !ok || observed != expectedMid { + return fmt.Errorf("runner: effect message id differs from the derived mid") + } + if observed, ok := events[0]["routeSequence"].(uint64); !ok || observed != attempt.RouteSequence { + return fmt.Errorf("runner: effect route sequence differs from the attempt") + } + consumed, err := receiver.call(ctx, "consumedAttempts", attemptKey) + if err != nil { + return err + } + if len(consumed) != 1 { + return fmt.Errorf("runner: consumedAttempts returned %d values", len(consumed)) + } + if value, ok := consumed[0].(bool); !ok || !value { + return fmt.Errorf("runner: destination effect is not visible") + } + observed, err := eventRow( + attempt.AttemptID, "destination_effect_observation", "observed", "coordinator_read", + destinationRole, len(attempt.Route), result.TransactionHash, + map[string]any{ + "receiver": receiver.address.Hex(), + "attempt_key": xir.FormatDigest(attemptKey), + "mid": xir.FormatDigest(expectedMid), + "route_sequence": attempt.RouteSequence, + "delivery_transaction": result.TransactionHash, + "effect_event_count": 1, + }, + ) + if err != nil { + return err + } + return r.state.RecordEvent(observed) +} + +func (r *Runner) checkStop() error { + if r.config.StopFile == "" { + return nil + } + if _, err := os.Stat(r.config.StopFile); err == nil { + return fmt.Errorf("runner: submissions stopped by %s", r.config.StopFile) + } + return nil +} + +func (r *Runner) signerFor(role string) (*evm.Signer, error) { + if signer, ok := r.runnerKeys[role]; ok { + return signer, nil + } + chain, ok := r.chainConfig[role] + if !ok { + return nil, fmt.Errorf("runner: no chain configuration for role %q", role) + } + signer, err := evm.NewSigner(r.config.Keys.Runner, new(big.Int).SetUint64(chain.ChainID)) + if err != nil { + return nil, err + } + r.runnerKeys[role] = signer + return signer, nil +} + +func (r *Runner) mustRunnerAddress(role string) common.Address { + signer, err := r.signerFor(role) + if err != nil { + return common.Address{} + } + return signer.Address() +} + +// rootSignerFor returns the root signer of the source chain. +func (r *Runner) rootSignerFor(role string) (*evm.Signer, error) { + chain, ok := r.chainConfig[role] + if !ok { + return nil, fmt.Errorf("runner: no chain configuration for role %q", role) + } + return evm.NewSigner(r.config.Keys.RootSigner, new(big.Int).SetUint64(chain.ChainID)) +} + +// spoolPath returns the durable raw-transaction path of one action, matching +// the naming the transactor uses (/.raw). +func spoolPath(b *bound, transactionHash string) string { + canonical := strings.ToLower(strings.TrimPrefix(transactionHash, "0x")) + return filepath.Join(b.transactor.SpoolRoot(), canonical+".raw") +} + +func mustAdapterKey(route string, hopIndex int, direction string) string { + key, err := xir.AdapterKey(route, hopIndex, direction) + if err != nil { + panic(err) + } + return key +} + +var _ = artifacts.Artifact{} diff --git a/go-runtime/internal/runner/types.go b/go-runtime/internal/runner/types.go new file mode 100644 index 0000000..1c0f289 --- /dev/null +++ b/go-runtime/internal/runner/types.go @@ -0,0 +1,199 @@ +package runner + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/lysrain21/XIR/go-runtime/internal/xir" +) + +// ChainConfig is one local chain the runtime may talk to. +type ChainConfig struct { + Role string + ChainID uint64 + RPCURL string + HyperlaneDomain uint32 + LayerZeroEID uint32 +} + +// Attempt is one route execution the runtime must perform. +type Attempt struct { + AttemptID string + Phase string + Route string + RouteSequence uint64 + SwitchCount int + PayloadSHA256 string +} + +// Stage names follow src/xir_lab/native/multihop_scalability.py +// expected_stage_sequence so the Go ledger stays readable by the Python +// analysis tooling. +const ( + StageRootCreate = "root_create" + StageDestinationDelivery = "destination_verify_deliver" +) + +func transitionStage(hopIndex int) string { + return fmt.Sprintf("hop_%d_xir_transition", hopIndex) +} + +func dispatchStage(hopIndex int, protocol string) string { + return fmt.Sprintf("hop_%d_%s_dispatch", hopIndex, strings.ToLower(protocol)) +} + +func hyperlaneProcessStage(hopIndex int) string { + return fmt.Sprintf("hop_%d_hyperlane_process", hopIndex) +} + +func layerZeroStage(hopIndex int, stage string) string { + return fmt.Sprintf("hop_%d_layerzero_%s", hopIndex, stage) +} + +func callbackStage(hopIndex int, protocol string) string { + return fmt.Sprintf("hop_%d_%s_callback", hopIndex, strings.ToLower(protocol)) +} + +// KeySet holds the private keys the runtime signs with. Keys are supplied by the +// caller (environment or an external custody file) and never persisted. +type KeySet struct { + Runner string + RootSigner string + LayerZeroWorker string + HyperlaneValidator string + HyperlaneRelayer string +} + +// Config is one runtime invocation. +type Config struct { + Attempts []Attempt + Chains []ChainConfig + DeploymentPath string + ArtifactsRoot string + // ProtocolRoot is the protocol-projects directory that holds the pinned + // Hyperlane and LayerZero Forge artifacts. + ProtocolRoot string + PayloadSchedule xir.PayloadSchedule + FixedSeed string + PolicyLabel string + Timeout time.Duration + PollInterval time.Duration + Concurrency int + StopFile string + StatePath string + RawRoot string + Keys KeySet + // EmbeddedAgents runs the LayerZero DVN/Executor and the Hyperlane + // validator/relayer roles inside this process. When false the runtime only + // dispatches and waits for external agents to deliver. + EmbeddedAgents bool + // OptionsOverride replaces the LayerZero executor options when set. + OptionsOverride []byte + // FaultAfterStage aborts an attempt right after the named stage is durably + // recorded. It exists for recovery tests and mirrors the fault injector the + // Python campaign uses in src/xir_lab/native/faults_v1.py. + FaultAfterStage string + // Finality is the confirmation rule applied before a creation is treated as + // signable. The default is the RPC finalized tag with the private-QBFT + // fallback, which is what the frozen lab uses. + Finality Finality + // GasLimit is the gas limit of runtime transactions. + GasLimit uint64 +} + +// Finality modes. +const ( + // FinalityRPCFinalized requires the creation block to reach the finalized + // height the node reports, falling back to the QBFT rule when the node does + // not implement the tag. + FinalityRPCFinalized = "rpc-finalized" + // FinalityConfirmations requires N successor blocks and confirms the + // creation block is still canonical. It is the rule for development chains + // that pin the finalized tag at genesis. + FinalityConfirmations = "confirmations" + // FinalityNone accepts the mined receipt. It is a test-only rule and is + // recorded as such in the ledger. + FinalityNone = "none" +) + +// Finality is the confirmation rule of one runtime invocation. +type Finality struct { + // Mode is one of FinalityRPCFinalized, FinalityConfirmations, or + // FinalityNone. + Mode string + // Confirmations is the successor depth the confirmations mode requires. + Confirmations uint64 + // Timeout bounds one confirmation wait. + Timeout time.Duration +} + +// Deployment is the contract manifest produced by internal/deploy. +type Deployment struct { + SchemaVersion string `json:"schema_version"` + Chains map[string]map[string]string `json:"chains"` + Infrastructure map[string]map[string]string `json:"infrastructure"` + Path string `json:"-"` + SHA256 string `json:"-"` +} + +// LoadDeployment reads a deployment document. +func LoadDeployment(path string) (*Deployment, error) { + payload, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("runner: read deployment: %w", err) + } + var document Deployment + if err := json.Unmarshal(payload, &document); err != nil { + return nil, fmt.Errorf("runner: decode deployment: %w", err) + } + if len(document.Chains) == 0 { + return nil, fmt.Errorf("runner: deployment %s has no chains", path) + } + document.Path = path + document.SHA256 = digestHex(payload) + return &document, nil +} + +// Contract resolves one application contract address by chain role and key. +func (d *Deployment) Contract(role, key string) (common.Address, error) { + chain, ok := d.Chains[role] + if !ok { + return common.Address{}, fmt.Errorf("runner: deployment has no chain %q", role) + } + value, ok := chain[key] + if !ok || value == "" { + return common.Address{}, fmt.Errorf("runner: chain %s has no contract %q", role, key) + } + if !common.IsHexAddress(value) { + return common.Address{}, fmt.Errorf("runner: chain %s contract %q is not an address", role, key) + } + return common.HexToAddress(value), nil +} + +// Infra resolves one protocol infrastructure address by chain role and key. +func (d *Deployment) Infra(role, key string) (common.Address, error) { + chain, ok := d.Infrastructure[role] + if !ok { + return common.Address{}, fmt.Errorf("runner: deployment has no infrastructure for chain %q", role) + } + value, ok := chain[key] + if !ok || value == "" { + return common.Address{}, fmt.Errorf("runner: chain %s has no infrastructure %q", role, key) + } + if !common.IsHexAddress(value) { + return common.Address{}, fmt.Errorf("runner: chain %s infrastructure %q is not an address", role, key) + } + return common.HexToAddress(value), nil +} + +func digestHex(payload []byte) string { + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]) +} diff --git a/go-runtime/internal/state/clock_linux.go b/go-runtime/internal/state/clock_linux.go new file mode 100644 index 0000000..d8a65ac --- /dev/null +++ b/go-runtime/internal/state/clock_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package state + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// monotonicNanos returns CLOCK_MONOTONIC nanoseconds, the same clock Python's +// time.monotonic_ns() reports for the durable event ledger. +func monotonicNanos() (int64, error) { + var stamp unix.Timespec + if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &stamp); err != nil { + return 0, fmt.Errorf("xir state: CLOCK_MONOTONIC is unavailable: %w", err) + } + return stamp.Nano(), nil +} + +// threadID returns the writing thread's kernel thread id, the Go equivalent of +// Python's threading.get_ident() evidence column. +func threadID() int { return unix.Gettid() } diff --git a/go-runtime/internal/state/clock_other.go b/go-runtime/internal/state/clock_other.go new file mode 100644 index 0000000..026974c --- /dev/null +++ b/go-runtime/internal/state/clock_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package state + +import ( + "os" + "time" +) + +var monotonicOrigin = time.Now() + +// monotonicNanos is the portable fallback for hosts without CLOCK_MONOTONIC. +// The durable runner target is Linux; the fallback keeps the package buildable +// elsewhere without silently reporting wall-clock nanoseconds. +func monotonicNanos() (int64, error) { + return int64(time.Since(monotonicOrigin)), nil +} + +// threadID has no portable kernel thread id; the process id keeps the column +// populated on hosts without Linux's gettid(2). +func threadID() int { return os.Getpid() } diff --git a/go-runtime/internal/state/pyjson.go b/go-runtime/internal/state/pyjson.go new file mode 100644 index 0000000..db6ac97 --- /dev/null +++ b/go-runtime/internal/state/pyjson.go @@ -0,0 +1,211 @@ +package state + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// CanonicalJSON renders a value exactly like the Python reference runtime's +// json.dumps(value, sort_keys=True): object keys sorted by code point, ", " as +// the item separator, ": " after each key, ASCII-only output, and no trailing +// newline. +// +// The Python runner stores every durable detail document with that call +// (“json.dumps(detail, sort_keys=True)“), so byte-identical rows are only +// reachable with the same separators and escaping. +func CanonicalJSON(value any) (string, error) { + return renderPythonJSON(value, "", ", ", ": ") +} + +// CanonicalJSONIndent renders a value like json.dumps(value, indent=n, +// sort_keys=True): keys sorted, one member per line, and a trailing newline is +// added by the caller. Python switches to the compact item separator "," once an +// indent is requested. +func CanonicalJSONIndent(value any, indent int) (string, error) { + if indent < 0 { + return "", fmt.Errorf("xir state: negative JSON indent %d", indent) + } + return renderPythonJSON(value, strings.Repeat(" ", indent), ",", ": ") +} + +func renderPythonJSON(value any, indent, itemSeparator, keySeparator string) (string, error) { + normalized, err := normalizeJSON(value) + if err != nil { + return "", err + } + var builder strings.Builder + if err := writePythonJSON(&builder, normalized, indent, "", itemSeparator, keySeparator); err != nil { + return "", err + } + return builder.String(), nil +} + +// normalizeJSON round-trips a value through encoding/json so structs, typed maps +// and named integer types all arrive as the plain JSON value tree, and so +// numbers keep the literal spelling encoding/json produced for them. +func normalizeJSON(value any) (any, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("xir state: JSON value is not encodable: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return nil, fmt.Errorf("xir state: JSON value is not decodable: %w", err) + } + return decoded, nil +} + +// DecodeJSON parses a JSON document with numbers kept as json.Number, so a +// receipt's integer fields and a frozen nonce are never rounded through float64. +func DecodeJSON(document []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + if decoder.More() { + return nil, fmt.Errorf("trailing JSON content after the first value") + } + return decoded, nil +} + +// decodeJSONValue is the string form of DecodeJSON, used by the detail +// documents this package writes and merges. +func decodeJSONValue(document string) (any, error) { return DecodeJSON([]byte(document)) } + +func writePythonJSON( + builder *strings.Builder, + value any, + indent string, + level string, + itemSeparator string, + keySeparator string, +) error { + switch typed := value.(type) { + case nil: + builder.WriteString("null") + case bool: + if typed { + builder.WriteString("true") + } else { + builder.WriteString("false") + } + case json.Number: + // normalizeJSON leaves every number as its literal spelling, so an + // integer never passes through a float and a float keeps the shortest + // representation encoding/json chose for it. + builder.WriteString(typed.String()) + case string: + writePythonString(builder, typed) + case []any: + if len(typed) == 0 { + builder.WriteString("[]") + return nil + } + child := level + indent + builder.WriteByte('[') + for index, item := range typed { + if index > 0 { + builder.WriteString(itemSeparator) + } + if indent != "" { + builder.WriteByte('\n') + builder.WriteString(child) + } + if err := writePythonJSON(builder, item, indent, child, itemSeparator, keySeparator); err != nil { + return err + } + } + if indent != "" { + builder.WriteByte('\n') + builder.WriteString(level) + } + builder.WriteByte(']') + case map[string]any: + if len(typed) == 0 { + builder.WriteString("{}") + return nil + } + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + child := level + indent + builder.WriteByte('{') + for index, key := range keys { + if index > 0 { + builder.WriteString(itemSeparator) + } + if indent != "" { + builder.WriteByte('\n') + builder.WriteString(child) + } + writePythonString(builder, key) + builder.WriteString(keySeparator) + if err := writePythonJSON(builder, typed[key], indent, child, itemSeparator, keySeparator); err != nil { + return err + } + } + if indent != "" { + builder.WriteByte('\n') + builder.WriteString(level) + } + builder.WriteByte('}') + default: + return fmt.Errorf("xir state: unsupported JSON value of type %T", value) + } + return nil +} + +// writePythonString escapes one string the way json.dumps does by default: +// ensure_ascii=True, with the short escapes for the five named control +// characters and lowercase \uXXXX for everything outside printable ASCII. +func writePythonString(builder *strings.Builder, value string) { + builder.WriteByte('"') + for _, character := range value { + switch character { + case '"': + builder.WriteString(`\"`) + case '\\': + builder.WriteString(`\\`) + case '\b': + builder.WriteString(`\b`) + case '\f': + builder.WriteString(`\f`) + case '\n': + builder.WriteString(`\n`) + case '\r': + builder.WriteString(`\r`) + case '\t': + builder.WriteString(`\t`) + default: + if character >= 0x20 && character <= 0x7e { + builder.WriteRune(character) + continue + } + if character == utf8.RuneError { + // json.dumps on a lone surrogate or invalid byte is not + // reachable from Go's UTF-8 strings; encode the replacement + // character instead of dropping the position. + builder.WriteString(`\ufffd`) + continue + } + if character <= 0xffff { + fmt.Fprintf(builder, `\u%04x`, character) + continue + } + high, low := utf16.EncodeRune(character) + fmt.Fprintf(builder, `\u%04x\u%04x`, high, low) + } + } + builder.WriteByte('"') +} diff --git a/go-runtime/internal/state/state.go b/go-runtime/internal/state/state.go new file mode 100644 index 0000000..0348b29 --- /dev/null +++ b/go-runtime/internal/state/state.go @@ -0,0 +1,1888 @@ +// Package state is the durable SQLite ledger for the XIR Go runtime. +// +// The schema mirrors the Python reference runner: “RunnerState“ in +// src/xir_lab/native/runner.py defines attempts, stages, stage_history, +// attempt_errors and the gateway nonce reservation, while +// “MultihopRunnerState“ in src/xir_lab/native/multihop_runner.py adds events, +// the durable signed-transaction table, and the stage/event evidence pairs. +// The actions table is the Go runtime's own frozen-intent ledger: one row per +// transaction, keyed by action_id, carrying the frozen calldata digest, the +// signed raw transaction and the receipt evidence. +// +// Durability rules, all inherited from the Python runtime: +// +// - journal_mode=WAL and synchronous=FULL, so a committed row has reached the +// disk before the call returns and before the matching network action runs; +// - a single writer (one pooled connection plus a process-wide mutex), so a +// writer never observes a partially applied multi-row write; +// - insert paths are idempotent, keyed by action_id or (attempt_id, stage); +// replaying an already-durable write is a no-op, while a replayed write with +// different bytes is reported as identity drift. +package state + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/sha3" + + sqlite "modernc.org/sqlite" +) + +// driverName is the modernc.org/sqlite database/sql driver name. +const driverName = "sqlite" + +// busyTimeoutMillis bounds how long SQLite waits for another writer's lock. +const busyTimeoutMillis = 5000 + +// Action states, in the order the transactor advances through them. “failed“ +// is terminal evidence of a reverted transaction. +const ( + ActionIntended = "intended" + ActionSigned = "signed" + ActionSubmitted = "submitted" + ActionSucceeded = "succeeded" + ActionFailed = "failed" +) + +// Attempt statuses from the Python attempts table. +const ( + AttemptRunning = "running" + AttemptSucceeded = "succeeded" +) + +// ErrClosed reports use of a closed store. +var ErrClosed = errors.New("xir state: store is closed") + +// ErrDrift reports a durable row whose bytes disagree with the value the caller +// is re-recording. The Python runtime treats the same condition as fatal +// (LocalTopologyError / sqlite3.IntegrityError), never as a retryable conflict. +var ErrDrift = errors.New("xir state: durable identity drift") + +// ErrStateTransition reports an invalid action state transition. +var ErrStateTransition = errors.New("xir state: invalid action state transition") + +// durabilityPragmas are applied to every pooled connection, so a connection the +// database/sql pool discards after an error is replaced by an equally durable +// one. without these, a replacement connection would silently run with SQLite's +// default synchronous=NORMAL journal policy. +var durabilityPragmas = []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=FULL", + fmt.Sprintf("PRAGMA busy_timeout=%d", busyTimeoutMillis), +} + +var ( + hookOnce sync.Once + hookMu sync.Mutex + hookPaths = map[string]struct{}{} +) + +// registerDurabilityHook installs the process-wide connection hook exactly once +// and remembers which database paths belong to this package, so the hook never +// changes the pragmas of a connection opened by another package. +func registerDurabilityHook() { + hookOnce.Do(func() { + sqlite.RegisterConnectionHook(func(conn sqlite.ExecQuerierContext, dsn string) error { + hookMu.Lock() + _, known := hookPaths[dsn] + hookMu.Unlock() + if !known { + return nil + } + for _, pragma := range durabilityPragmas { + if _, err := conn.ExecContext(context.Background(), pragma, nil); err != nil { + return fmt.Errorf("xir state: %q failed: %w", pragma, err) + } + } + return nil + }) + }) +} + +// Store is the durable runner ledger. +type Store struct { + mu sync.Mutex + db *sql.DB + path string + bootID string + closed bool +} + +// Open opens (creating when absent) the runner database at path and applies the +// frozen schema. The parent directory is created with mode 0700 because the +// durable signed transactions are stored in this file. +func Open(path string) (*Store, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("xir state: empty database path") + } + clean := filepath.Clean(path) + if directory := filepath.Dir(clean); directory != "." && directory != "" { + if _, err := os.Stat(directory); errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, fmt.Errorf("xir state: cannot create %s: %w", directory, err) + } + } + } + bootID, err := readBootID() + if err != nil { + return nil, err + } + registerDurabilityHook() + hookMu.Lock() + hookPaths[clean] = struct{}{} + hookMu.Unlock() + releaseHook := func() { + hookMu.Lock() + delete(hookPaths, clean) + hookMu.Unlock() + } + db, err := sql.Open(driverName, clean) + if err != nil { + releaseHook() + return nil, fmt.Errorf("xir state: cannot open %s: %w", clean, err) + } + // One writer: every statement in this process is serialized behind a single + // connection, which is also what keeps the pragmas above in force. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + db.SetConnMaxIdleTime(0) + if err := db.Ping(); err != nil { + db.Close() + releaseHook() + return nil, fmt.Errorf("xir state: cannot reach %s: %w", clean, err) + } + store := &Store{db: db, path: clean, bootID: bootID} + if err := store.applySchema(); err != nil { + db.Close() + releaseHook() + return nil, err + } + if err := store.verifyDurability(); err != nil { + db.Close() + releaseHook() + return nil, err + } + if err := os.Chmod(clean, 0o600); err != nil { + db.Close() + releaseHook() + return nil, fmt.Errorf("xir state: cannot restrict %s: %w", clean, err) + } + return store, nil +} + +// Close releases the database handle. The final frames of the write-ahead log +// are already durable, so nothing further is required for crash safety. +func (s *Store) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + hookMu.Lock() + delete(hookPaths, s.path) + hookMu.Unlock() + if err := s.db.Close(); err != nil { + return fmt.Errorf("xir state: cannot close %s: %w", s.path, err) + } + return nil +} + +// Path returns the database path the store was opened with. +func (s *Store) Path() string { return s.path } + +// BootID returns the host boot identity stamped into every event row. +func (s *Store) BootID() string { return s.bootID } + +func readBootID() (string, error) { + const bootIDPath = "/proc/sys/kernel/random/boot_id" + raw, err := os.ReadFile(bootIDPath) + if err != nil { + return "", fmt.Errorf("xir state: host boot identity is unavailable: %w", err) + } + value := strings.TrimSpace(string(raw)) + if value == "" { + return "", errors.New("xir state: host boot identity is empty") + } + return value, nil +} + +// schemaTables mirror the Python runner DDL statement for statement, with two +// deliberate additions: the actions ledger, and the process_identity_sha256 +// column that the Python multihop state adds by migration. Indexes are created +// after the column migrations, because one of them covers a migrated column. +var schemaTables = []string{ + `CREATE TABLE IF NOT EXISTS attempts( + attempt_id TEXT PRIMARY KEY, + phase TEXT NOT NULL, + route TEXT NOT NULL, + route_sequence INTEGER NOT NULL, + coordinates_json TEXT NOT NULL, + status TEXT NOT NULL, + started_at REAL NOT NULL, + finished_at REAL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS stages( + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id), + stage TEXT NOT NULL, + state TEXT NOT NULL, + transaction_hash TEXT, + detail_json TEXT NOT NULL, + observed_at REAL NOT NULL, + PRIMARY KEY(attempt_id, stage) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS stage_history( + history_id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id), + stage TEXT NOT NULL, + state TEXT NOT NULL, + transaction_hash TEXT, + detail_json TEXT NOT NULL, + observed_at REAL NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS attempt_errors( + error_id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id), + error_class TEXT NOT NULL, + error_message TEXT NOT NULL, + retry_index INTEGER NOT NULL, + observed_at REAL NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS events( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id), + stage TEXT NOT NULL, + event TEXT NOT NULL, + source TEXT NOT NULL, + chain_role TEXT, + hop_index INTEGER, + transaction_hash TEXT, + utc_ns INTEGER NOT NULL, + monotonic_ns INTEGER NOT NULL, + boot_id TEXT NOT NULL, + process_id INTEGER NOT NULL, + process_identity_sha256 TEXT, + thread_id INTEGER NOT NULL, + detail_json TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS durable_signed_transactions( + attempt_id TEXT NOT NULL REFERENCES attempts(attempt_id), + stage TEXT NOT NULL, + transaction_hash TEXT NOT NULL UNIQUE, + raw_transaction BLOB NOT NULL, + raw_sha256 TEXT NOT NULL, + detail_json TEXT NOT NULL, + PRIMARY KEY(attempt_id, stage) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS actions( + action_id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL, + stage TEXT NOT NULL, + chain_role TEXT NOT NULL, + chain_id INTEGER NOT NULL, + sender TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL, + calldata_hex TEXT NOT NULL, + calldata_bytes INTEGER NOT NULL, + calldata_sha256 TEXT NOT NULL, + value TEXT NOT NULL, + gas INTEGER NOT NULL, + nonce INTEGER NOT NULL, + state TEXT NOT NULL, + raw_transaction_hex TEXT, + transaction_hash TEXT, + receipt_path TEXT, + receipt_sha256 TEXT, + block_number INTEGER, + gas_used INTEGER, + detail_json TEXT NOT NULL, + intended_at REAL NOT NULL, + updated_at REAL NOT NULL + ) STRICT`, +} + +// schemaIndexes are created after the column migrations. +var schemaIndexes = []string{ + `CREATE UNIQUE INDEX IF NOT EXISTS events_unique_boundary + ON events(attempt_id, stage, event, source)`, + // One nonce is spent by exactly one action of one account, mirroring + // native_action_intents' UNIQUE(chain_id, actor_public_id, nonce). A failed + // action releases its nonce again. + // + // The earlier revision keyed this guard on the chain role instead of the + // sender, which is wrong on a chain where the runtime and an embedded + // protocol agent both act, and it left its rows without an account. Those + // rows are excluded here: they cannot be attributed, so they neither raise a + // nonce floor nor block a migration whose own constraint they used to + // violate. + `DROP INDEX IF EXISTS actions_nonce_unique`, + `CREATE UNIQUE INDEX IF NOT EXISTS actions_nonce_sender_unique + ON actions(chain_id, sender, nonce) + WHERE state <> 'failed' AND sender <> ''`, + `CREATE INDEX IF NOT EXISTS actions_attempt_stage + ON actions(attempt_id, stage)`, +} + +func (s *Store) applySchema() error { + for _, statement := range schemaTables { + if _, err := s.db.Exec(statement); err != nil { + return fmt.Errorf("xir state: schema statement failed: %w", err) + } + } + if err := s.migrateEventColumns(); err != nil { + return err + } + if err := s.migrateActionSender(); err != nil { + return err + } + for _, statement := range schemaIndexes { + if _, err := s.db.Exec(statement); err != nil { + return fmt.Errorf("xir state: schema index failed: %w", err) + } + } + return nil +} + +// migrateActionSender adds the sender column to an actions table created by an +// earlier revision. Rows written before the column existed keep an empty sender; +// they stay readable, but they no longer contribute to any account's nonce +// floor. +func (s *Store) migrateActionSender() error { + present, err := s.columnPresent("actions", "sender") + if err != nil { + return err + } + if present { + return nil + } + if _, err := s.db.Exec("ALTER TABLE actions ADD COLUMN sender TEXT NOT NULL DEFAULT ''"); err != nil { + return fmt.Errorf("xir state: cannot migrate actions: %w", err) + } + return nil +} + +func (s *Store) columnPresent(table, column string) (bool, error) { + rows, err := s.db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return false, fmt.Errorf("xir state: cannot inspect %s: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var ( + index int + name string + columnType string + notNull int + defaultValue sql.NullString + primaryKey int + ) + if err := rows.Scan(&index, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return false, fmt.Errorf("xir state: cannot inspect %s: %w", table, err) + } + if name == column { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, fmt.Errorf("xir state: cannot inspect %s: %w", table, err) + } + return false, nil +} + +// migrateEventColumns adds process_identity_sha256 to an events table created by +// an earlier revision, exactly as MultihopRunnerState does. +func (s *Store) migrateEventColumns() error { + present, err := s.columnPresent("events", "process_identity_sha256") + if err != nil { + return err + } + if present { + return nil + } + if _, err := s.db.Exec("ALTER TABLE events ADD COLUMN process_identity_sha256 TEXT"); err != nil { + return fmt.Errorf("xir state: cannot migrate events: %w", err) + } + return nil +} + +// verifyDurability proves the pragmas are in force before the store is used. A +// silent downgrade to synchronous=NORMAL would let a committed intent disappear +// in a crash, so a mismatch is fatal. +func (s *Store) verifyDurability() error { + var journalMode string + if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil { + return fmt.Errorf("xir state: cannot read journal_mode: %w", err) + } + if strings.ToLower(journalMode) != "wal" { + return fmt.Errorf("xir state: journal_mode is %q, want \"wal\"", journalMode) + } + var synchronous int + if err := s.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil { + return fmt.Errorf("xir state: cannot read synchronous: %w", err) + } + const synchronousFull = 2 + if synchronous != synchronousFull { + return fmt.Errorf("xir state: synchronous is %d, want %d (FULL)", synchronous, synchronousFull) + } + var busyTimeout int + if err := s.db.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout); err != nil { + return fmt.Errorf("xir state: cannot read busy_timeout: %w", err) + } + if busyTimeout != busyTimeoutMillis { + return fmt.Errorf("xir state: busy_timeout is %d, want %d", busyTimeout, busyTimeoutMillis) + } + return nil +} + +// transact runs fn inside one write transaction under the single-writer mutex. +// Commit returns only after the WAL frame carrying the change is fsynced +// (synchronous=FULL), which is why every recording path can hand control to a +// network action as soon as it returns. +func (s *Store) transact(fn func(*sql.Tx) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ErrClosed + } + tx, err := s.db.BeginTx(context.Background(), nil) + if err != nil { + return fmt.Errorf("xir state: cannot begin a write: %w", err) + } + if err := fn(tx); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + return fmt.Errorf("%w (rollback also failed: %v)", err, rollbackErr) + } + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("xir state: cannot commit: %w", err) + } + return nil +} + +func (s *Store) query(fn func(*sql.DB) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ErrClosed + } + return fn(s.db) +} + +// Action is one durable transaction intent with its frozen calldata, signed raw +// transaction, and receipt evidence. +type Action struct { + ActionID string + AttemptID string + Stage string + ChainRole string + ChainID uint64 + // Sender is the lowercase hex address of the account that signed the + // transaction. Nonces are account-scoped, so the reservation floor must be + // computed per (chain, sender) rather than per chain. + Sender string + To string + CalldataHex string + CalldataBytes int + CalldataSHA256 string + Value string + Gas uint64 + Nonce uint64 + State string + RawTransactionHex string + TransactionHash string + ReceiptPath string + ReceiptSHA256 string + BlockNumber uint64 + GasUsed uint64 + DetailJSON string +} + +// ActionResult is the evidence a caller records when an action succeeds. +type ActionResult struct { + TransactionHash string + BlockNumber uint64 + GasUsed uint64 + ReceiptPath string + ReceiptSHA256 string + // ContractAddress is set for contract-creation transactions, matching + // deployer.py's receipt["contractAddress"] lookup. + ContractAddress string + Detail map[string]any +} + +// AttemptRow mirrors one row of the attempts table. +type AttemptRow struct { + AttemptID string + Phase string + Route string + RouteSequence int + CoordinatesJSON string + Status string + StartedAt float64 + FinishedAt *float64 +} + +// StageRow mirrors one row of the stages table. +type StageRow struct { + AttemptID string + Stage string + State string + TransactionHash *string + DetailJSON string + ObservedAt float64 +} + +// EventRow mirrors one row of the events table. RecordEvent stamps the dual +// clock, the host boot identity, the writing thread and the process id when the +// caller leaves them zero, exactly like MultihopRunnerState.record_event. +type EventRow struct { + AttemptID string + Stage string + Event string + Source string + ChainRole string + HopIndex *int + TransactionHash string + UTCNs int64 + MonotonicNs int64 + BootID string + ProcessID int + ProcessIdentitySHA256 string + ThreadID int + DetailJSON string +} + +// Action returns one action row, or nil when it does not exist. +func (s *Store) Action(actionID string) (*Action, error) { + if actionID == "" { + return nil, errors.New("xir state: empty action id") + } + var action *Action + err := s.query(func(db *sql.DB) error { + row := db.QueryRow(actionSelect+" WHERE action_id = ?", actionID) + scanned, err := scanAction(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + action = scanned + return nil + }) + if err != nil { + return nil, err + } + return action, nil +} + +// PendingActions returns every action that has signed bytes but no receipt yet, +// ordered like MultihopRunnerState.pending_signed_transactions so a restarted +// process revalidates and resubmits them in a stable order. +func (s *Store) PendingActions() ([]Action, error) { + var actions []Action + err := s.query(func(db *sql.DB) error { + rows, err := db.Query(actionSelect + ` WHERE state IN ('signed','submitted') + ORDER BY attempt_id, stage`) + if err != nil { + return fmt.Errorf("xir state: cannot read pending actions: %w", err) + } + defer rows.Close() + for rows.Next() { + action, err := scanAction(rows) + if err != nil { + return err + } + actions = append(actions, *action) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("xir state: cannot read pending actions: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return actions, nil +} + +// MaxActionNonce returns the highest nonce the given sender has claimed on one +// chain, which is what lets a restarted transactor avoid re-using the nonce of a +// signed transaction that never reached the network. +// +// The sender is part of the key because a nonce is spent per account: a chain in +// this runtime carries the runner and the embedded Hyperlane and LayerZero +// agents, so an unsigned transaction floor computed across accounts would hand +// the runner a nonce another account had already used. Rows written before the +// sender column existed carry an empty sender and therefore contribute nothing; +// an empty sender is refused rather than answered from those rows. +func (s *Store) MaxActionNonce(chainID uint64, sender string) (uint64, bool, error) { + sender = strings.ToLower(strings.TrimSpace(sender)) + if sender == "" { + return 0, false, errors.New("xir state: a nonce floor needs the signing account") + } + var ( + maximum sql.NullInt64 + found bool + ) + err := s.query(func(db *sql.DB) error { + row := db.QueryRow( + "SELECT MAX(nonce) FROM actions WHERE chain_id = ? AND sender = ?", + int64(chainID), sender, + ) + if err := row.Scan(&maximum); err != nil { + return fmt.Errorf("xir state: cannot read the maximum action nonce: %w", err) + } + found = maximum.Valid + return nil + }) + if err != nil { + return 0, false, err + } + if !found { + return 0, false, nil + } + return uint64(maximum.Int64), true, nil +} + +const actionSelect = `SELECT action_id, attempt_id, stage, chain_role, chain_id, sender, + target, calldata_hex, calldata_bytes, calldata_sha256, value, gas, nonce, state, + raw_transaction_hex, transaction_hash, receipt_path, receipt_sha256, + block_number, gas_used, detail_json + FROM actions` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanAction(row rowScanner) (*Action, error) { + var ( + action Action + chainID int64 + gas int64 + nonce int64 + blockNumber sql.NullInt64 + gasUsed sql.NullInt64 + rawTx sql.NullString + transaction sql.NullString + receiptPath sql.NullString + receiptSHA256 sql.NullString + ) + if err := row.Scan( + &action.ActionID, + &action.AttemptID, + &action.Stage, + &action.ChainRole, + &chainID, + &action.Sender, + &action.To, + &action.CalldataHex, + &action.CalldataBytes, + &action.CalldataSHA256, + &action.Value, + &gas, + &nonce, + &action.State, + &rawTx, + &transaction, + &receiptPath, + &receiptSHA256, + &blockNumber, + &gasUsed, + &action.DetailJSON, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, err + } + return nil, fmt.Errorf("xir state: cannot decode an action row: %w", err) + } + action.ChainID = uint64(chainID) + action.Gas = uint64(gas) + action.Nonce = uint64(nonce) + action.RawTransactionHex = rawTx.String + action.TransactionHash = transaction.String + action.ReceiptPath = receiptPath.String + action.ReceiptSHA256 = receiptSHA256.String + action.BlockNumber = uint64(blockNumber.Int64) + action.GasUsed = uint64(gasUsed.Int64) + return &action, nil +} + +// Detail decodes the durable detail document of the action. +func (a Action) Detail() (map[string]any, error) { + if strings.TrimSpace(a.DetailJSON) == "" { + return map[string]any{}, nil + } + return decodeDetail(a.DetailJSON) +} + +// Intend durably freezes one transaction intent. Replaying the identical intent +// returns the stored row; replaying the same action_id with different frozen +// bytes is identity drift. +func (s *Store) Intend(action Action) (*Action, error) { + if err := validateIntend(action); err != nil { + return nil, err + } + if action.State == "" { + action.State = ActionIntended + } + if action.DetailJSON == "" { + action.DetailJSON = "{}" + } + action.Sender = strings.ToLower(strings.TrimSpace(action.Sender)) + stored, err := s.Action(action.ActionID) + if err != nil { + return nil, err + } + if stored != nil { + if err := sameFrozenIdentity(*stored, action); err != nil { + return nil, err + } + return stored, nil + } + now := epochSeconds(time.Now()) + err = s.transact(func(tx *sql.Tx) error { + _, err := tx.Exec( + `INSERT INTO actions( + action_id, attempt_id, stage, chain_role, chain_id, sender, + target, calldata_hex, calldata_bytes, calldata_sha256, value, + gas, nonce, state, detail_json, intended_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + action.ActionID, action.AttemptID, action.Stage, action.ChainRole, + int64(action.ChainID), action.Sender, action.To, action.CalldataHex, + action.CalldataBytes, action.CalldataSHA256, action.Value, + int64(action.Gas), int64(action.Nonce), action.State, action.DetailJSON, + now, now, + ) + if err != nil { + return fmt.Errorf("xir state: cannot record action %s: %w", action.ActionID, err) + } + return nil + }) + if err != nil { + return nil, err + } + return s.Action(action.ActionID) +} + +func validateIntend(action Action) error { + if action.ActionID == "" { + return errors.New("xir state: an intent needs an action id") + } + if action.ChainRole == "" { + return fmt.Errorf("xir state: action %s has no chain role", action.ActionID) + } + if action.Sender == "" { + return fmt.Errorf("xir state: action %s has no sender account", action.ActionID) + } + if action.State != "" && action.State != ActionIntended { + return fmt.Errorf( + "xir state: action %s must be intended at insert time, got %q", + action.ActionID, action.State, + ) + } + if action.Gas == 0 { + return fmt.Errorf("xir state: action %s froze a zero gas limit", action.ActionID) + } + calldata, err := decodeHex(action.CalldataHex) + if err != nil { + return fmt.Errorf("xir state: action %s has invalid calldata: %w", action.ActionID, err) + } + if len(calldata) != action.CalldataBytes { + return fmt.Errorf( + "%w: action %s froze %d calldata bytes but carries %d", + ErrDrift, action.ActionID, action.CalldataBytes, len(calldata), + ) + } + if err := requireDigest(action.CalldataSHA256, "calldata_sha256", action.ActionID); err != nil { + return err + } + if observed := sha256Hex(calldata); observed != action.CalldataSHA256 { + return fmt.Errorf( + "%w: action %s froze calldata_sha256 %s but its bytes digest to %s", + ErrDrift, action.ActionID, action.CalldataSHA256, observed, + ) + } + if action.Value == "" { + return fmt.Errorf("xir state: action %s has no value", action.ActionID) + } + return nil +} + +// sameFrozenIdentity compares the fields a restart must be able to re-derive +// byte for byte. +func sameFrozenIdentity(stored, requested Action) error { + compare := []struct { + name string + stored string + replayed string + }{ + {"attempt_id", stored.AttemptID, requested.AttemptID}, + {"stage", stored.Stage, requested.Stage}, + {"chain_role", stored.ChainRole, requested.ChainRole}, + {"sender", stored.Sender, strings.ToLower(strings.TrimSpace(requested.Sender))}, + {"target", stored.To, requested.To}, + {"calldata_hex", stored.CalldataHex, requested.CalldataHex}, + {"calldata_sha256", stored.CalldataSHA256, requested.CalldataSHA256}, + {"value", stored.Value, requested.Value}, + } + for _, field := range compare { + if field.stored != field.replayed { + return fmt.Errorf( + "%w: action %s %s is %q in the ledger and %q in the replay", + ErrDrift, stored.ActionID, field.name, field.stored, field.replayed, + ) + } + } + if stored.ChainID != requested.ChainID { + return fmt.Errorf( + "%w: action %s chain_id is %d in the ledger and %d in the replay", + ErrDrift, stored.ActionID, stored.ChainID, requested.ChainID, + ) + } + if stored.Gas != requested.Gas { + return fmt.Errorf( + "%w: action %s gas is %d in the ledger and %d in the replay", + ErrDrift, stored.ActionID, stored.Gas, requested.Gas, + ) + } + if stored.Nonce != requested.Nonce { + return fmt.Errorf( + "%w: action %s nonce is %d in the ledger and %d in the replay", + ErrDrift, stored.ActionID, stored.Nonce, requested.Nonce, + ) + } + if stored.CalldataBytes != requested.CalldataBytes { + return fmt.Errorf( + "%w: action %s calldata_bytes is %d in the ledger and %d in the replay", + ErrDrift, stored.ActionID, stored.CalldataBytes, requested.CalldataBytes, + ) + } + return nil +} + +// RecordSigned binds the raw signed transaction and its hash to the frozen +// intent, then mirrors the write into the Python runner's signed-transaction +// ledger when the action carries an attempt and a stage. The write is atomic: +// either the raw bytes, the transaction identity, the stage rows and the +// boundary events are all durable, or none of them are. +func (s *Store) RecordSigned(actionID string, rawTx []byte, txHash string) error { + if actionID == "" { + return errors.New("xir state: RecordSigned needs an action id") + } + if len(rawTx) == 0 { + return fmt.Errorf("xir state: action %s has no raw transaction bytes", actionID) + } + canonical, err := canonicalHash(txHash) + if err != nil { + return fmt.Errorf("xir state: action %s: %w", actionID, err) + } + if observed := keccakHex(rawTx); observed != canonical { + return fmt.Errorf( + "%w: action %s was handed transaction hash %s but its bytes hash to %s", + ErrDrift, actionID, canonical, observed, + ) + } + action, err := s.Action(actionID) + if err != nil { + return err + } + if action == nil { + return fmt.Errorf("xir state: action %s was never intended", actionID) + } + if action.RawTransactionHex != "" { + existing, err := decodeHex(action.RawTransactionHex) + if err != nil { + return fmt.Errorf("%w: action %s has undecodable raw bytes: %v", ErrDrift, actionID, err) + } + if !bytesEqual(existing, rawTx) || action.TransactionHash != canonical { + return fmt.Errorf( + "%w: action %s already holds signed transaction %s", + ErrDrift, actionID, action.TransactionHash, + ) + } + if action.State == ActionSucceeded || action.State == ActionFailed { + return nil + } + } + if err := checkTransition(action.State, ActionSigned); err != nil { + return err + } + signedDetail, err := s.signedDetail(action, rawTx) + if err != nil { + return err + } + rawSHA256 := sha256Hex(rawTx) + now := epochSeconds(time.Now()) + err = s.transact(func(tx *sql.Tx) error { + if _, err := tx.Exec( + `UPDATE actions + SET state = ?, raw_transaction_hex = ?, transaction_hash = ?, + detail_json = ?, updated_at = ? + WHERE action_id = ?`, + ActionSigned, encodeHex(rawTx), canonical, signedDetail, now, actionID, + ); err != nil { + return fmt.Errorf("xir state: cannot record signed action %s: %w", actionID, err) + } + if action.AttemptID == "" || action.Stage == "" { + return nil + } + if err := insertDurableSigned(tx, action, canonical, rawTx, rawSHA256, signedDetail); err != nil { + return err + } + intended, err := intendedDetail(action) + if err != nil { + return err + } + if err := insertStageAndEvent(tx, stageWrite{ + attemptID: action.AttemptID, + stage: action.Stage, + state: ActionIntended, + detailJSON: intended, + transactionHash: nil, + observedAt: now, + updateCurrent: false, + chainRole: action.ChainRole, + bootID: s.bootID, + }); err != nil { + return err + } + return insertStageAndEvent(tx, stageWrite{ + attemptID: action.AttemptID, + stage: action.Stage, + state: ActionSigned, + detailJSON: signedDetail, + transactionHash: &canonical, + observedAt: now, + updateCurrent: true, + chainRole: action.ChainRole, + bootID: s.bootID, + }) + }) + if err != nil { + return err + } + return nil +} + +// signedDetail merges the frozen action identity with the caller's detail +// document and the raw transaction digest, mirroring the `signed_detail` the +// Python runner writes into durable_signed_transactions. +func (s *Store) signedDetail(action *Action, rawTx []byte) (string, error) { + detail, err := intendedDetail(action) + if err != nil { + return "", err + } + fields, err := decodeDetail(detail) + if err != nil { + return "", err + } + fields["raw_sha256"] = sha256Hex(rawTx) + return CanonicalJSON(fields) +} + +// intendedDetail is the frozen-intent document the Python runner records before +// signing: the chain role, the reserved nonce, the target and the calldata +// digest, plus whatever the caller froze in the action detail. +func intendedDetail(action *Action) (string, error) { + fields, err := action.Detail() + if err != nil { + return "", err + } + fields["role"] = action.ChainRole + fields["nonce"] = action.Nonce + fields["target"] = action.To + fields["calldata_sha256"] = action.CalldataSHA256 + fields["chain_id"] = action.ChainID + return CanonicalJSON(fields) +} + +func insertDurableSigned( + tx *sql.Tx, + action *Action, + txHash string, + rawTx []byte, + rawSHA256 string, + detailJSON string, +) error { + var ( + existingHash sql.NullString + existingSHA sql.NullString + existingRaw []byte + ) + err := tx.QueryRow( + `SELECT transaction_hash, raw_sha256, raw_transaction + FROM durable_signed_transactions + WHERE attempt_id = ? AND stage = ?`, + action.AttemptID, action.Stage, + ).Scan(&existingHash, &existingSHA, &existingRaw) + switch { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.Exec( + `INSERT INTO durable_signed_transactions( + attempt_id, stage, transaction_hash, raw_transaction, + raw_sha256, detail_json + ) VALUES (?,?,?,?,?,?)`, + action.AttemptID, action.Stage, txHash, rawTx, rawSHA256, detailJSON, + ); err != nil { + return fmt.Errorf( + "xir state: cannot record durable signed transaction for %s/%s: %w", + action.AttemptID, action.Stage, err, + ) + } + return nil + case err != nil: + return fmt.Errorf("xir state: cannot read durable signed transactions: %w", err) + } + if existingHash.String != txHash || existingSHA.String != rawSHA256 || !bytesEqual(existingRaw, rawTx) { + return fmt.Errorf( + "%w: durable signed transaction for %s/%s already holds %s", + ErrDrift, action.AttemptID, action.Stage, existingHash.String, + ) + } + return nil +} + +// Observe advances an action to a new state and merges detail into its durable +// detail document, mirroring the runner's stage/event boundary recording. +func (s *Store) Observe(actionID, stateName string, detail map[string]any) error { + if _, err := actionRank(stateName); err != nil { + return err + } + action, err := s.Action(actionID) + if err != nil { + return err + } + if action == nil { + return fmt.Errorf("xir state: action %s was never intended", actionID) + } + switch stateName { + case ActionSigned, ActionSubmitted, ActionSucceeded: + // A signed or submitted action without its raw bytes cannot be + // revalidated after a restart, so the transition is refused here rather + // than discovered later. + if action.RawTransactionHex == "" { + return fmt.Errorf( + "xir state: action %s cannot become %s before its raw transaction is recorded", + actionID, stateName, + ) + } + } + if action.State == stateName { + if len(detail) == 0 { + return nil + } + } else if err := checkTransition(action.State, stateName); err != nil { + return err + } + merged, err := mergeDetail(action.DetailJSON, detail) + if err != nil { + return err + } + var transactionHash *string + if action.TransactionHash != "" { + hash := action.TransactionHash + transactionHash = &hash + } + now := epochSeconds(time.Now()) + return s.transact(func(tx *sql.Tx) error { + if _, err := tx.Exec( + `UPDATE actions SET state = ?, detail_json = ?, updated_at = ? + WHERE action_id = ?`, + stateName, merged, now, actionID, + ); err != nil { + return fmt.Errorf("xir state: cannot observe action %s: %w", actionID, err) + } + if action.AttemptID == "" || action.Stage == "" { + return nil + } + fields, err := decodeDetail(merged) + if err != nil { + return err + } + return insertStageAndEvent(tx, stageWrite{ + attemptID: action.AttemptID, + stage: action.Stage, + state: stateName, + detailJSON: merged, + transactionHash: transactionHash, + observedAt: now, + updateCurrent: true, + chainRole: actionDetailString(fields, "role"), + hopIndex: actionDetailInt(fields, "hop_index"), + bootID: s.bootID, + }) + }) +} + +// Succeed records the final receipt evidence and closes the action. +func (s *Store) Succeed(actionID string, result ActionResult) error { + canonical, err := canonicalHash(result.TransactionHash) + if err != nil { + return fmt.Errorf("xir state: action %s: %w", actionID, err) + } + if result.ReceiptPath != "" { + if err := requireDigest(result.ReceiptSHA256, "receipt_sha256", actionID); err != nil { + return err + } + } + action, err := s.Action(actionID) + if err != nil { + return err + } + if action == nil { + return fmt.Errorf("xir state: action %s was never intended", actionID) + } + if action.TransactionHash != "" && action.TransactionHash != canonical { + return fmt.Errorf( + "%w: action %s succeeded on %s but the ledger holds %s", + ErrDrift, actionID, canonical, action.TransactionHash, + ) + } + if action.State == ActionSucceeded { + if action.BlockNumber == result.BlockNumber && action.GasUsed == result.GasUsed { + return nil + } + return fmt.Errorf( + "%w: action %s already succeeded at block %d with %d gas", + ErrDrift, actionID, action.BlockNumber, action.GasUsed, + ) + } + if err := checkTransition(action.State, ActionSucceeded); err != nil { + return err + } + merged, err := mergeDetail(action.DetailJSON, result.Detail) + if err != nil { + return err + } + if result.ContractAddress != "" { + merged, err = mergeDetail(merged, map[string]any{"contract_address": result.ContractAddress}) + if err != nil { + return err + } + } + if result.ReceiptPath != "" { + merged, err = mergeDetail(merged, map[string]any{ + "receipt": result.ReceiptPath, + "receipt_sha256": result.ReceiptSHA256, + "gas_used": result.GasUsed, + "block_number": result.BlockNumber, + }) + if err != nil { + return err + } + } + var transactionHash *string + if canonical != "" { + transactionHash = &canonical + } + now := epochSeconds(time.Now()) + return s.transact(func(tx *sql.Tx) error { + if _, err := tx.Exec( + `UPDATE actions + SET state = ?, transaction_hash = COALESCE(NULLIF(?, ''), transaction_hash), + receipt_path = ?, receipt_sha256 = ?, block_number = ?, + gas_used = ?, detail_json = ?, updated_at = ? + WHERE action_id = ?`, + ActionSucceeded, canonical, result.ReceiptPath, result.ReceiptSHA256, + int64(result.BlockNumber), int64(result.GasUsed), merged, now, actionID, + ); err != nil { + return fmt.Errorf("xir state: cannot complete action %s: %w", actionID, err) + } + if action.AttemptID == "" || action.Stage == "" { + return nil + } + return insertStageAndEvent(tx, stageWrite{ + attemptID: action.AttemptID, + stage: action.Stage, + state: ActionSucceeded, + detailJSON: merged, + transactionHash: transactionHash, + observedAt: now, + updateCurrent: true, + chainRole: action.ChainRole, + bootID: s.bootID, + }) + }) +} + +// BeginAttempt registers one attempt, or validates that a durable attempt still +// matches the frozen coordinates. It reports whether the caller should execute +// the attempt: false means it already succeeded. A durable attempt whose phase, +// route, sequence or coordinates differ from the frozen plan is drift. +func (s *Store) BeginAttempt(attempt AttemptRow) (bool, error) { + if attempt.AttemptID == "" { + return false, errors.New("xir state: an attempt needs an attempt id") + } + now := epochSeconds(time.Now()) + existing, err := s.attempt(attempt.AttemptID) + if err != nil { + return false, err + } + if existing != nil { + fields := []struct { + name string + stored string + frozen string + }{ + {"phase", existing.Phase, attempt.Phase}, + {"route", existing.Route, attempt.Route}, + {"coordinates_json", existing.CoordinatesJSON, attempt.CoordinatesJSON}, + } + for _, field := range fields { + if field.stored != field.frozen { + return false, fmt.Errorf( + "%w: attempt %s %s is %q in the ledger and %q in the frozen plan", + ErrDrift, attempt.AttemptID, field.name, field.stored, field.frozen, + ) + } + } + if existing.RouteSequence != attempt.RouteSequence { + return false, fmt.Errorf( + "%w: attempt %s route_sequence is %d in the ledger and %d in the frozen plan", + ErrDrift, attempt.AttemptID, existing.RouteSequence, attempt.RouteSequence, + ) + } + return existing.Status != AttemptSucceeded, nil + } + err = s.transact(func(tx *sql.Tx) error { + if _, err := tx.Exec( + `INSERT INTO attempts( + attempt_id, phase, route, route_sequence, coordinates_json, + status, started_at + ) VALUES (?,?,?,?,?,?,?)`, + attempt.AttemptID, attempt.Phase, attempt.Route, attempt.RouteSequence, + attempt.CoordinatesJSON, AttemptRunning, now, + ); err != nil { + return fmt.Errorf("xir state: cannot begin attempt %s: %w", attempt.AttemptID, err) + } + return nil + }) + if err != nil { + return false, err + } + return true, nil +} + +// FinishAttempt marks the attempt succeeded and stamps its finish time. +func (s *Store) FinishAttempt(attemptID string) error { + return s.transact(func(tx *sql.Tx) error { + result, err := tx.Exec( + "UPDATE attempts SET status = ?, finished_at = ? WHERE attempt_id = ?", + AttemptSucceeded, epochSeconds(time.Now()), attemptID, + ) + if err != nil { + return fmt.Errorf("xir state: cannot finish attempt %s: %w", attemptID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("xir state: cannot finish attempt %s: %w", attemptID, err) + } + if affected == 0 { + return fmt.Errorf("xir state: attempt %s was never begun", attemptID) + } + return nil + }) +} + +func (s *Store) attempt(attemptID string) (*AttemptRow, error) { + var stored *AttemptRow + err := s.query(func(db *sql.DB) error { + row := db.QueryRow( + `SELECT attempt_id, phase, route, route_sequence, coordinates_json, + status, started_at, finished_at + FROM attempts WHERE attempt_id = ?`, + attemptID, + ) + var ( + attempt AttemptRow + finishedAt sql.NullFloat64 + startedAt float64 + ) + if err := row.Scan( + &attempt.AttemptID, &attempt.Phase, &attempt.Route, + &attempt.RouteSequence, &attempt.CoordinatesJSON, &attempt.Status, + &startedAt, &finishedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("xir state: cannot decode an attempt row: %w", err) + } + attempt.StartedAt = startedAt + if finishedAt.Valid { + value := finishedAt.Float64 + attempt.FinishedAt = &value + } + stored = &attempt + return nil + }) + if err != nil { + return nil, err + } + return stored, nil +} + +// RecordStage appends one stage boundary to stage_history, updates the current +// stage row, and records the matching idempotent event, exactly like +// MultihopRunnerState.record_stage. +func (s *Store) RecordStage(row StageRow) error { + if row.AttemptID == "" || row.Stage == "" { + return errors.New("xir state: a stage row needs an attempt id and a stage") + } + if row.DetailJSON == "" { + row.DetailJSON = "{}" + } + if row.ObservedAt == 0 { + row.ObservedAt = epochSeconds(time.Now()) + } + return s.transact(func(tx *sql.Tx) error { + return insertStageAndEvent(tx, stageWrite{ + attemptID: row.AttemptID, + stage: row.Stage, + state: row.State, + detailJSON: row.DetailJSON, + transactionHash: row.TransactionHash, + observedAt: row.ObservedAt, + updateCurrent: true, + chainRole: "", + bootID: s.bootID, + }) + }) +} + +// Stage returns the current row for one attempt stage, or nil when the stage has +// not been recorded yet. +func (s *Store) Stage(attemptID, stage string) (*StageRow, error) { + var stored *StageRow + err := s.query(func(db *sql.DB) error { + row := db.QueryRow( + `SELECT attempt_id, stage, state, transaction_hash, detail_json, observed_at + FROM stages WHERE attempt_id = ? AND stage = ?`, + attemptID, stage, + ) + var ( + result StageRow + transaction sql.NullString + ) + if err := row.Scan( + &result.AttemptID, &result.Stage, &result.State, &transaction, + &result.DetailJSON, &result.ObservedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("xir state: cannot decode a stage row: %w", err) + } + if transaction.Valid { + value := transaction.String + result.TransactionHash = &value + } + stored = &result + return nil + }) + if err != nil { + return nil, err + } + return stored, nil +} + +// RecordEvent appends one durable event boundary. The events table carries a +// unique index on (attempt_id, stage, event, source), so replaying the same +// boundary after a restart is a no-op rather than a duplicate. +func (s *Store) RecordEvent(row EventRow) error { + if row.AttemptID == "" || row.Stage == "" || row.Event == "" || row.Source == "" { + return errors.New("xir state: an event row needs attempt, stage, event and source") + } + if row.DetailJSON == "" { + row.DetailJSON = "{}" + } + if row.BootID == "" { + row.BootID = s.bootID + } + if row.ProcessID == 0 { + row.ProcessID = os.Getpid() + } + if row.ThreadID == 0 { + row.ThreadID = threadID() + } + if row.UTCNs == 0 { + row.UTCNs = time.Now().UnixNano() + } + if row.MonotonicNs == 0 { + observed, err := monotonicNanos() + if err != nil { + return err + } + row.MonotonicNs = observed + } + return s.transact(func(tx *sql.Tx) error { + if _, err := tx.Exec( + `INSERT OR IGNORE INTO events( + attempt_id, stage, event, source, chain_role, hop_index, + transaction_hash, utc_ns, monotonic_ns, boot_id, process_id, + process_identity_sha256, thread_id, detail_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + row.AttemptID, row.Stage, row.Event, row.Source, + nullableString(row.ChainRole), nullableInt(row.HopIndex), + nullableString(row.TransactionHash), row.UTCNs, row.MonotonicNs, + row.BootID, row.ProcessID, nullableString(row.ProcessIdentitySHA256), + row.ThreadID, row.DetailJSON, + ); err != nil { + return fmt.Errorf("xir state: cannot record event %s/%s: %w", row.AttemptID, row.Event, err) + } + return nil + }) +} + +// RecordError appends one transient failure with its per-attempt retry index, +// mirroring RunnerState.record_transient_error. +func (s *Store) RecordError(attemptID, class, message string) error { + if attemptID == "" { + return errors.New("xir state: an error row needs an attempt id") + } + if class == "" { + return errors.New("xir state: an error row needs an error class") + } + return s.transact(func(tx *sql.Tx) error { + var retryIndex int + if err := tx.QueryRow( + "SELECT COUNT(*) FROM attempt_errors WHERE attempt_id = ?", attemptID, + ).Scan(&retryIndex); err != nil { + return fmt.Errorf("xir state: cannot count attempt errors: %w", err) + } + if _, err := tx.Exec( + `INSERT INTO attempt_errors( + attempt_id, error_class, error_message, retry_index, observed_at + ) VALUES (?,?,?,?,?)`, + attemptID, class, message, retryIndex+1, epochSeconds(time.Now()), + ); err != nil { + return fmt.Errorf("xir state: cannot record an attempt error: %w", err) + } + return nil + }) +} + +// NextReservedRootNonce returns the next gateway record nonce this ledger has +// reserved. Include Go pre-signing action intents and the root stages used +// by both Python runners (root_create and xir_root_record). +func (s *Store) NextReservedRootNonce() (uint64, error) { + var ( + maximum sql.NullInt64 + next uint64 + ) + err := s.query(func(db *sql.DB) error { + row := db.QueryRow( + `SELECT MAX(CAST(json_extract(detail_json, '$.record_nonce') AS INTEGER)) AS maximum + FROM ( + SELECT detail_json FROM stages WHERE stage IN ('root_create', 'xir_root_record') + UNION ALL + SELECT json_extract(detail_json, '$.intent_detail') AS detail_json + FROM actions WHERE stage = 'root_create' + )`, + ) + if err := row.Scan(&maximum); err != nil { + return fmt.Errorf("xir state: cannot read the reserved root nonce: %w", err) + } + return nil + }) + if err != nil { + return 0, err + } + if !maximum.Valid { + return 0, nil + } + next = uint64(maximum.Int64) + 1 + return next, nil +} + +// VerifyActionIdentity re-derives the frozen identity of an action row and +// rejects any mismatch: the calldata bytes must digest to the frozen calldata +// digest and have the frozen length, and the raw transaction must hash to the +// recorded transaction hash. A row edited outside the store therefore fails +// loudly instead of being rebroadcast. +func VerifyActionIdentity(action *Action) error { + if action == nil { + return errors.New("xir state: no action to verify") + } + if action.ActionID == "" { + return errors.New("xir state: action row has no action id") + } + if action.ChainRole == "" { + return fmt.Errorf("xir state: action %s has no chain role", action.ActionID) + } + if action.Sender == "" { + return fmt.Errorf("xir state: action %s has no sender account", action.ActionID) + } + if _, err := actionRank(action.State); err != nil { + return fmt.Errorf("xir state: action %s: %w", action.ActionID, err) + } + if action.Gas == 0 { + return fmt.Errorf("xir state: action %s froze a zero gas limit", action.ActionID) + } + calldata, err := decodeHex(action.CalldataHex) + if err != nil { + return fmt.Errorf("%w: action %s has undecodable calldata: %v", ErrDrift, action.ActionID, err) + } + if len(calldata) != action.CalldataBytes { + return fmt.Errorf( + "%w: action %s carries %d calldata bytes but froze %d", + ErrDrift, action.ActionID, len(calldata), action.CalldataBytes, + ) + } + if err := requireDigest(action.CalldataSHA256, "calldata_sha256", action.ActionID); err != nil { + return err + } + if observed := sha256Hex(calldata); observed != action.CalldataSHA256 { + return fmt.Errorf( + "%w: action %s carries calldata digest %s but froze %s", + ErrDrift, action.ActionID, observed, action.CalldataSHA256, + ) + } + if action.ReceiptSHA256 != "" { + if err := requireDigest(action.ReceiptSHA256, "receipt_sha256", action.ActionID); err != nil { + return err + } + } + switch action.State { + case ActionSigned, ActionSubmitted, ActionSucceeded: + default: + return nil + } + if action.RawTransactionHex == "" || action.TransactionHash == "" { + return fmt.Errorf( + "%w: action %s is %s but holds no signed transaction", + ErrDrift, action.ActionID, action.State, + ) + } + raw, err := decodeHex(action.RawTransactionHex) + if err != nil { + return fmt.Errorf("%w: action %s has undecodable raw bytes: %v", ErrDrift, action.ActionID, err) + } + if observed := keccakHex(raw); observed != action.TransactionHash { + return fmt.Errorf( + "%w: action %s carries raw bytes hashing to %s but records %s", + ErrDrift, action.ActionID, observed, action.TransactionHash, + ) + } + return nil +} + +func actionRank(stateName string) (int, error) { + switch stateName { + case ActionIntended: + return 0, nil + case ActionSigned: + return 1, nil + case ActionSubmitted: + return 2, nil + case ActionSucceeded: + return 3, nil + case ActionFailed: + return 4, nil + default: + return 0, fmt.Errorf("xir state: unknown action state %q", stateName) + } +} + +func checkTransition(from, to string) error { + if from == "" { + return nil + } + if from == to { + return nil + } + fromRank, err := actionRank(from) + if err != nil { + return err + } + toRank, err := actionRank(to) + if err != nil { + return err + } + if from == ActionFailed { + return fmt.Errorf("%w: %s is terminal and cannot become %s", ErrStateTransition, from, to) + } + if from == ActionSucceeded { + return fmt.Errorf("%w: %s is terminal and cannot become %s", ErrStateTransition, from, to) + } + if to != ActionFailed && toRank < fromRank { + return fmt.Errorf("%w: %s cannot regress to %s", ErrStateTransition, from, to) + } + return nil +} + +type stageWrite struct { + attemptID string + stage string + state string + detailJSON string + transactionHash *string + observedAt float64 + updateCurrent bool + chainRole string + hopIndex *int + bootID string +} + +// insertStageAndEvent implements the Python runner's shared stage/event write: +// one append to stage_history, an upsert of the current stage row when the +// caller asks for it, and one idempotent event boundary. +func insertStageAndEvent(tx *sql.Tx, write stageWrite) error { + if _, err := tx.Exec( + `INSERT INTO stage_history( + attempt_id, stage, state, transaction_hash, detail_json, observed_at + ) VALUES (?,?,?,?,?,?)`, + write.attemptID, write.stage, write.state, write.transactionHash, + write.detailJSON, write.observedAt, + ); err != nil { + return fmt.Errorf( + "xir state: cannot record stage history for %s/%s: %w", + write.attemptID, write.stage, err, + ) + } + if write.updateCurrent { + if _, err := tx.Exec( + `INSERT INTO stages( + attempt_id, stage, state, transaction_hash, detail_json, observed_at + ) VALUES (?,?,?,?,?,?) + ON CONFLICT(attempt_id, stage) DO UPDATE SET + state=excluded.state, + transaction_hash=excluded.transaction_hash, + detail_json=excluded.detail_json, + observed_at=excluded.observed_at`, + write.attemptID, write.stage, write.state, write.transactionHash, + write.detailJSON, write.observedAt, + ); err != nil { + return fmt.Errorf( + "xir state: cannot record stage %s/%s: %w", write.attemptID, write.stage, err, + ) + } + } + fields, err := decodeDetail(write.detailJSON) + if err != nil { + return err + } + chainRole := write.chainRole + if chainRole == "" { + chainRole = actionDetailString(fields, "role") + } + hopIndex := write.hopIndex + if hopIndex == nil { + hopIndex = actionDetailInt(fields, "hop_index") + } + // The Python multihop state embeds its process identity in every event + // detail document. The Go runtime leaves process identity to the caller + // (runner orchestration), so the event detail is the stage detail itself. + eventDetail, err := CanonicalJSON(fields) + if err != nil { + return err + } + utcNs := time.Now().UnixNano() + monotonic, err := monotonicNanos() + if err != nil { + return err + } + if _, err := tx.Exec( + `INSERT OR IGNORE INTO events( + attempt_id, stage, event, source, chain_role, hop_index, + transaction_hash, utc_ns, monotonic_ns, boot_id, process_id, + process_identity_sha256, thread_id, detail_json + ) VALUES (?,?,?,'coordinator',?,?,?,?,?,?,?,?,?,?)`, + write.attemptID, write.stage, write.state, nullableString(chainRole), + nullableInt(hopIndex), nullableStringPointer(write.transactionHash), + utcNs, monotonic, write.bootID, os.Getpid(), nil, threadID(), eventDetail, + ); err != nil { + return fmt.Errorf( + "xir state: cannot record the %s event for %s/%s: %w", + write.state, write.attemptID, write.stage, err, + ) + } + return nil +} + +func mergeDetail(existing string, addition map[string]any) (string, error) { + fields, err := decodeDetail(existing) + if err != nil { + return "", err + } + return mergeDetailFields(fields, addition) +} + +func mergeDetailFields(fields map[string]any, addition map[string]any) (string, error) { + for key, value := range addition { + fields[key] = value + } + return CanonicalJSON(fields) +} + +func decodeDetail(document string) (map[string]any, error) { + if strings.TrimSpace(document) == "" { + return map[string]any{}, nil + } + decoded, err := decodeJSONValue(document) + if err != nil { + return nil, fmt.Errorf("xir state: detail document is not valid JSON: %w", err) + } + fields, ok := decoded.(map[string]any) + if !ok { + return nil, fmt.Errorf("xir state: detail document is a %T, want an object", decoded) + } + return fields, nil +} + +func actionDetailString(fields map[string]any, key string) string { + value, ok := fields[key] + if !ok { + return "" + } + text, ok := value.(string) + if !ok { + return "" + } + return text +} + +func actionDetailInt(fields map[string]any, key string) *int { + value, ok := fields[key] + if !ok { + return nil + } + switch typed := value.(type) { + case int: + return &typed + case int64: + converted := int(typed) + return &converted + case float64: + converted := int(typed) + return &converted + case json.Number: + converted, err := typed.Int64() + if err != nil { + return nil + } + value := int(converted) + return &value + default: + return nil + } +} + +func requireDigest(value, field, actionID string) error { + if len(value) != 64 { + return fmt.Errorf( + "xir state: action %s %s is %q, want 64 hex characters", + actionID, field, value, + ) + } + if _, err := hex.DecodeString(value); err != nil { + return fmt.Errorf("xir state: action %s %s is not hex: %w", actionID, field, err) + } + if strings.ToLower(value) != value { + return fmt.Errorf("xir state: action %s %s is not lowercase hex", actionID, field) + } + return nil +} + +// canonicalHash normalises a transaction hash to the 0x-prefixed lowercase form +// the Python runner writes ("0x" + value.lower().removeprefix("0x")). +func canonicalHash(value string) (string, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x") + trimmed = strings.TrimPrefix(trimmed, "0X") + if trimmed == "" { + return "", errors.New("empty transaction hash") + } + if len(trimmed)%2 != 0 { + return "", fmt.Errorf("transaction hash %q has an odd number of hex digits", value) + } + decoded, err := hex.DecodeString(strings.ToLower(trimmed)) + if err != nil { + return "", fmt.Errorf("transaction hash %q is not hex: %w", value, err) + } + return "0x" + hex.EncodeToString(decoded), nil +} + +func decodeHex(value string) ([]byte, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + trimmed = strings.TrimPrefix(trimmed, "0x") + trimmed = strings.TrimPrefix(trimmed, "0X") + if len(trimmed)%2 != 0 { + return nil, fmt.Errorf("hex string has an odd number of digits") + } + decoded, err := hex.DecodeString(strings.ToLower(trimmed)) + if err != nil { + return nil, err + } + return decoded, nil +} + +func encodeHex(value []byte) string { + return "0x" + hex.EncodeToString(value) +} + +func sha256Hex(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func keccakHex(value []byte) string { + hasher := sha3.NewLegacyKeccak256() + hasher.Write(value) + return "0x" + hex.EncodeToString(hasher.Sum(nil)) +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func nullableInt(value *int) any { + if value == nil { + return nil + } + return int64(*value) +} + +func nullableStringPointer(value *string) any { + if value == nil || *value == "" { + return nil + } + return *value +} + +// epochSeconds mirrors Python's time.time(): a float number of seconds since the +// epoch, stored in a REAL column. +func epochSeconds(now time.Time) float64 { + return float64(now.UnixNano()) / float64(time.Second) +} diff --git a/go-runtime/internal/state/state_test.go b/go-runtime/internal/state/state_test.go new file mode 100644 index 0000000..950dda8 --- /dev/null +++ b/go-runtime/internal/state/state_test.go @@ -0,0 +1,780 @@ +package state + +import ( + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/sha3" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + store, err := Open(filepath.Join(t.TempDir(), "runner", "runner.sqlite")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + return store +} + +func keccakHexOf(t *testing.T, raw []byte) string { + t.Helper() + hasher := sha3.NewLegacyKeccak256() + hasher.Write(raw) + return "0x" + hex.EncodeToString(hasher.Sum(nil)) +} + +func hexOf(data []byte) string { return "0x" + hex.EncodeToString(data) } + +// testSender is the account the state tests freeze actions for. +const testSender = "0x00000000000000000000000000000000000000f0" + +func testAction(calldata []byte, nonce uint64, role string) Action { + return Action{ + ActionID: fmt.Sprintf("action-%s-%d-%s", role, nonce, hexOf(calldata)[2:]), + AttemptID: "attempt-1", + Stage: "hop_0_h_dispatch", + ChainRole: role, + ChainID: 3133701, + Sender: testSender, + To: "0x00000000000000000000000000000000000000aa", + CalldataHex: hexOf(calldata), + CalldataBytes: len(calldata), + CalldataSHA256: sha256Hex(calldata), + Value: "0", + Gas: 12_000_000, + Nonce: nonce, + DetailJSON: `{"role": "` + role + `"}`, + } +} + +func TestOpenAppliesDurablePragmas(t *testing.T) { + store := openTestStore(t) + var journalMode string + if err := store.db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil { + t.Fatalf("journal_mode: %v", err) + } + if journalMode != "wal" { + t.Errorf("journal_mode = %q, want wal", journalMode) + } + var synchronous int + if err := store.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil { + t.Fatalf("synchronous: %v", err) + } + if synchronous != 2 { + t.Errorf("synchronous = %d, want 2 (FULL)", synchronous) + } + var busyTimeout int + if err := store.db.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout); err != nil { + t.Fatalf("busy_timeout: %v", err) + } + if busyTimeout != busyTimeoutMillis { + t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMillis) + } + info, err := os.Stat(store.Path()) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("database mode = %o, want 600", mode) + } + directory, err := os.Stat(filepath.Dir(store.Path())) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if mode := directory.Mode().Perm(); mode != 0o700 { + t.Errorf("database directory mode = %o, want 700", mode) + } +} + +func TestCanonicalJSONMatchesPythonDumps(t *testing.T) { + compact, err := CanonicalJSON(map[string]any{ + "b": 1, + "a": []any{1, 2, "x"}, + "u": "caf\u00e9", + "f": 0.1, + "n": nil, + "t": true, + }) + if err != nil { + t.Fatalf("CanonicalJSON: %v", err) + } + const wantCompact = `{"a": [1, 2, "x"], "b": 1, "f": 0.1, "n": null, "t": true, "u": "caf\u00e9"}` + if compact != wantCompact { + t.Errorf("CanonicalJSON =\n%s\nwant\n%s", compact, wantCompact) + } + indented, err := CanonicalJSONIndent(map[string]any{"b": 1, "a": map[string]any{"c": 2}, "e": []any{}}, 2) + if err != nil { + t.Fatalf("CanonicalJSONIndent: %v", err) + } + const wantIndented = "{\n \"a\": {\n \"c\": 2\n },\n \"b\": 1,\n \"e\": []\n}" + if indented != wantIndented { + t.Errorf("CanonicalJSONIndent =\n%s\nwant\n%s", indented, wantIndented) + } +} + +// TestOpenMigratesALegacyActionsTable proves a store written by the revision +// before the sender column still opens: the column is added, the account-scoped +// nonce index replaces the role-scoped one, and the legacy rows stay readable +// without ever lifting another account's nonce floor. +func TestOpenMigratesALegacyActionsTable(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy", "runner.sqlite") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + legacy, err := sql.Open(driverName, path) + if err != nil { + t.Fatalf("legacy open: %v", err) + } + legacyStatements := []string{ + `CREATE TABLE actions( + action_id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL, + stage TEXT NOT NULL, + chain_role TEXT NOT NULL, + chain_id INTEGER NOT NULL, + target TEXT NOT NULL, + calldata_hex TEXT NOT NULL, + calldata_bytes INTEGER NOT NULL, + calldata_sha256 TEXT NOT NULL, + value TEXT NOT NULL, + gas INTEGER NOT NULL, + nonce INTEGER NOT NULL, + state TEXT NOT NULL, + raw_transaction_hex TEXT, + transaction_hash TEXT, + receipt_path TEXT, + receipt_sha256 TEXT, + block_number INTEGER, + gas_used INTEGER, + detail_json TEXT NOT NULL, + intended_at REAL NOT NULL, + updated_at REAL NOT NULL + ) STRICT`, + `CREATE UNIQUE INDEX actions_nonce_unique + ON actions(chain_id, chain_role, nonce) WHERE state <> 'failed'`, + // Two of the embedded agents shared a nonce on chain b under the older + // revision, so the migrated store has to survive unattributable rows. + `INSERT INTO actions( + action_id, attempt_id, stage, chain_role, chain_id, target, + calldata_hex, calldata_bytes, calldata_sha256, value, gas, nonce, + state, detail_json, intended_at, updated_at + ) VALUES ( + 'legacy-agent-action', 'attempt-agent', 'hop_1_h_dispatch', 'b-relayer', 3133702, + '0x00000000000000000000000000000000000000ab', '0x03', 1, + '` + sha256Hex([]byte{3}) + `', '0', 12000000, 3, 'submitted', '{}', 1.0, 1.0 + )`, + `INSERT INTO actions( + action_id, attempt_id, stage, chain_role, chain_id, target, + calldata_hex, calldata_bytes, calldata_sha256, value, gas, nonce, + state, raw_transaction_hex, transaction_hash, detail_json, + intended_at, updated_at + ) VALUES ( + 'legacy-action', 'attempt-legacy', 'root_create', 'b', 3133702, + '0x00000000000000000000000000000000000000aa', '0x0102', 2, + '` + sha256Hex([]byte{1, 2}) + `', '0', 12000000, 3, 'submitted', + '0x02f86c0180', '0x` + strings.Repeat("77", 32) + `', '{}', 1.0, 1.0 + )`, + } + for _, statement := range legacyStatements { + if _, err := legacy.Exec(statement); err != nil { + legacy.Close() + t.Fatalf("legacy schema: %v", err) + } + } + if err := legacy.Close(); err != nil { + t.Fatalf("legacy close: %v", err) + } + + store, err := Open(path) + if err != nil { + t.Fatalf("Open a legacy store: %v", err) + } + defer store.Close() + legacyAction, err := store.Action("legacy-action") + if err != nil { + t.Fatalf("Action: %v", err) + } + if legacyAction == nil { + t.Fatal("the legacy action disappeared") + } + if legacyAction.Sender != "" || legacyAction.State != ActionSubmitted { + t.Fatalf("migrated legacy action = %+v", legacyAction) + } + if _, found, err := store.MaxActionNonce(3133702, testSender); err != nil { + t.Fatalf("MaxActionNonce: %v", err) + } else if found { + t.Fatal("a legacy row with no sender must not report a nonce floor") + } + // A current account may still claim nonce 3 on that chain. + replacement := testAction([]byte{9}, 3, "b") + replacement.ActionID = "action-after-migration" + replacement.ChainID = 3133702 + if _, err := store.Intend(replacement); err != nil { + t.Fatalf("Intend after migration: %v", err) + } + present, err := store.columnPresent("actions", "sender") + if err != nil || !present { + t.Fatalf("columnPresent(sender) = %v, %v", present, err) + } + var indexes int + if err := store.db.QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'actions_nonce_sender_unique'", + ).Scan(&indexes); err != nil { + t.Fatalf("index lookup: %v", err) + } + if indexes != 1 { + t.Fatalf("account-scoped nonce index count = %d, want 1", indexes) + } +} + +func TestAttemptLedgerResumesAndRejectsDrift(t *testing.T) { + store := openTestStore(t) + attempt := AttemptRow{ + AttemptID: "attempt-1", + Phase: "smoke", + Route: "HL", + RouteSequence: 0, + CoordinatesJSON: `{"attempt_id": "attempt-1", "route": "HL"}`, + } + shouldRun, err := store.BeginAttempt(attempt) + if err != nil { + t.Fatalf("BeginAttempt: %v", err) + } + if !shouldRun { + t.Fatal("a new attempt must run") + } + shouldRun, err = store.BeginAttempt(attempt) + if err != nil { + t.Fatalf("BeginAttempt replay: %v", err) + } + if !shouldRun { + t.Fatal("a running attempt must be resumed") + } + drifted := attempt + drifted.CoordinatesJSON = `{"attempt_id": "attempt-1", "route": "LH"}` + if _, err := store.BeginAttempt(drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted coordinates error = %v, want ErrDrift", err) + } + drifted = attempt + drifted.RouteSequence = 3 + if _, err := store.BeginAttempt(drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted route sequence error = %v, want ErrDrift", err) + } + if err := store.FinishAttempt(attempt.AttemptID); err != nil { + t.Fatalf("FinishAttempt: %v", err) + } + shouldRun, err = store.BeginAttempt(attempt) + if err != nil { + t.Fatalf("BeginAttempt after success: %v", err) + } + if shouldRun { + t.Fatal("a succeeded attempt must not run again") + } + if err := store.FinishAttempt("attempt-unknown"); err == nil { + t.Fatal("FinishAttempt on an unknown attempt must fail") + } +} + +func TestStageEventAndErrorLedger(t *testing.T) { + store := openTestStore(t) + if _, err := store.BeginAttempt(AttemptRow{ + AttemptID: "attempt-1", Phase: "smoke", Route: "HL", + CoordinatesJSON: `{"attempt_id": "attempt-1"}`, + }); err != nil { + t.Fatalf("BeginAttempt: %v", err) + } + if err := store.RecordStage(StageRow{ + AttemptID: "attempt-1", + Stage: "root_create", + State: "intended", + DetailJSON: `{"role": "a", "hop_index": 0, "record_nonce": 4}`, + }); err != nil { + t.Fatalf("RecordStage intended: %v", err) + } + transactionHash := "0x" + strings.Repeat("ab", 32) + if err := store.RecordStage(StageRow{ + AttemptID: "attempt-1", + Stage: "root_create", + State: "succeeded", + TransactionHash: &transactionHash, + DetailJSON: `{"role": "a", "hop_index": 0, "record_nonce": 4}`, + }); err != nil { + t.Fatalf("RecordStage succeeded: %v", err) + } + stage, err := store.Stage("attempt-1", "root_create") + if err != nil { + t.Fatalf("Stage: %v", err) + } + if stage == nil || stage.State != "succeeded" || stage.TransactionHash == nil || + *stage.TransactionHash != transactionHash { + t.Fatalf("Stage = %+v, want the succeeded boundary", stage) + } + if missing, err := store.Stage("attempt-1", "nowhere"); err != nil || missing != nil { + t.Fatalf("Stage(nowhere) = %v, %v; want nil, nil", missing, err) + } + var history int + if err := store.db.QueryRow( + "SELECT COUNT(*) FROM stage_history WHERE attempt_id = ? AND stage = ?", + "attempt-1", "root_create", + ).Scan(&history); err != nil { + t.Fatalf("stage_history count: %v", err) + } + if history != 2 { + t.Errorf("stage_history rows = %d, want 2", history) + } + var events int + if err := store.db.QueryRow( + "SELECT COUNT(*) FROM events WHERE attempt_id = ?", "attempt-1", + ).Scan(&events); err != nil { + t.Fatalf("events count: %v", err) + } + if events != 2 { + t.Errorf("event rows = %d, want 2 (intended, succeeded)", events) + } + // The events table carries a unique boundary index, so replaying the same + // boundary after a restart must not duplicate it. + if err := store.RecordStage(StageRow{ + AttemptID: "attempt-1", Stage: "root_create", State: "succeeded", + TransactionHash: &transactionHash, + DetailJSON: `{"role": "a", "hop_index": 0, "record_nonce": 4}`, + }); err != nil { + t.Fatalf("RecordStage replay: %v", err) + } + if err := store.db.QueryRow( + "SELECT COUNT(*) FROM events WHERE attempt_id = ?", "attempt-1", + ).Scan(&events); err != nil { + t.Fatalf("events count: %v", err) + } + if events != 2 { + t.Errorf("event rows after replay = %d, want 2", events) + } + var bootID string + var monotonic, utc int64 + var processID, threadID int + if err := store.db.QueryRow( + `SELECT boot_id, monotonic_ns, utc_ns, process_id, thread_id FROM events + WHERE attempt_id = ? AND event = 'succeeded'`, "attempt-1", + ).Scan(&bootID, &monotonic, &utc, &processID, &threadID); err != nil { + t.Fatalf("event columns: %v", err) + } + if bootID != store.BootID() || bootID == "" { + t.Errorf("boot_id = %q, want %q", bootID, store.BootID()) + } + if monotonic <= 0 || utc <= 0 { + t.Errorf("clocks = %d, %d; want positive", monotonic, utc) + } + if processID != os.Getpid() || threadID <= 0 { + t.Errorf("process/thread = %d, %d", processID, threadID) + } + for index, want := range []int64{1, 2} { + if err := store.RecordError("attempt-1", "TransportError", "rpc timed out"); err != nil { + t.Fatalf("RecordError %d: %v", index, err) + } + var retryIndex int64 + if err := store.db.QueryRow( + "SELECT MAX(retry_index) FROM attempt_errors WHERE attempt_id = ?", "attempt-1", + ).Scan(&retryIndex); err != nil { + t.Fatalf("retry_index: %v", err) + } + if retryIndex != want { + t.Fatalf("retry_index = %d, want %d", retryIndex, want) + } + } + next, err := store.NextReservedRootNonce() + if err != nil { + t.Fatalf("NextReservedRootNonce: %v", err) + } + if next != 5 { + t.Errorf("NextReservedRootNonce = %d, want 5 (record_nonce 4 + 1)", next) + } +} + +func TestNextReservedRootNonceWithoutRootStages(t *testing.T) { + store := openTestStore(t) + next, err := store.NextReservedRootNonce() + if err != nil { + t.Fatalf("NextReservedRootNonce: %v", err) + } + if next != 0 { + t.Errorf("NextReservedRootNonce = %d, want 0", next) + } +} + +func TestIntendIsIdempotentAndRejectsDrift(t *testing.T) { + store := openTestStore(t) + action := testAction([]byte{1, 2, 3, 4}, 7, "a") + stored, err := store.Intend(action) + if err != nil { + t.Fatalf("Intend: %v", err) + } + if stored.State != ActionIntended || stored.Nonce != 7 || stored.Gas != 12_000_000 { + t.Fatalf("Intend = %+v", stored) + } + replayed, err := store.Intend(action) + if err != nil { + t.Fatalf("Intend replay: %v", err) + } + if replayed.ActionID != stored.ActionID || replayed.CalldataSHA256 != stored.CalldataSHA256 { + t.Fatalf("Intend replay = %+v, want the stored row", replayed) + } + drifted := action + drifted.Nonce = 8 + if _, err := store.Intend(drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted nonce error = %v, want ErrDrift", err) + } + drifted = action + drifted.Sender = "0x00000000000000000000000000000000000000f1" + if _, err := store.Intend(drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted sender error = %v, want ErrDrift", err) + } + drifted = action + drifted.CalldataHex = hexOf([]byte{9, 9}) + drifted.CalldataBytes = 2 + drifted.CalldataSHA256 = sha256Hex([]byte{9, 9}) + if _, err := store.Intend(drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted calldata error = %v, want ErrDrift", err) + } + noSender := testAction([]byte{1}, 12, "b") + noSender.Sender = "" + if _, err := store.Intend(noSender); err == nil { + t.Fatal("an intent without an account must be rejected") + } + invalid := testAction([]byte{1}, 9, "b") + invalid.Gas = 0 + if _, err := store.Intend(invalid); err == nil { + t.Fatal("a zero gas intent must be rejected") + } + invalid = testAction([]byte{1}, 9, "b") + invalid.CalldataSHA256 = strings.Repeat("0", 64) + if _, err := store.Intend(invalid); !errors.Is(err, ErrDrift) { + t.Fatalf("calldata digest mismatch error = %v, want ErrDrift", err) + } + invalid = testAction([]byte{1}, 9, "b") + invalid.CalldataBytes = 5 + if _, err := store.Intend(invalid); !errors.Is(err, ErrDrift) { + t.Fatalf("calldata length mismatch error = %v, want ErrDrift", err) + } +} + +func TestActionNonceIsSpentOnce(t *testing.T) { + store := openTestStore(t) + first := testAction([]byte{1}, 3, "a") + if _, err := store.Intend(first); err != nil { + t.Fatalf("Intend first: %v", err) + } + second := testAction([]byte{2}, 3, "a") + second.ActionID = "action-second" + if _, err := store.Intend(second); err == nil { + t.Fatal("two live actions must not share a nonce") + } + // A failed action releases its nonce again. + if err := store.Observe(first.ActionID, ActionFailed, map[string]any{"error_class": "Reverted"}); err != nil { + t.Fatalf("Observe failed: %v", err) + } + if _, err := store.Intend(second); err != nil { + t.Fatalf("Intend after the earlier action failed: %v", err) + } + resurrected, err := store.Intend(first) + if err != nil { + t.Fatalf("Intend replay of a failed action: %v", err) + } + if resurrected.State != ActionFailed { + t.Fatalf("replaying a failed intent resurrected it as %q", resurrected.State) + } +} + +// TestMaxActionNonceIsAccountScoped is the regression guard for a real defect: +// the floor that protects a signed-but-unbroadcast nonce must be computed per +// account, because one chain in this runtime carries the runner and the embedded +// protocol agents, each with its own nonce space. +func TestMaxActionNonceIsAccountScoped(t *testing.T) { + store := openTestStore(t) + other := "0x00000000000000000000000000000000000000f1" + first, err := store.Intend(testAction([]byte{1}, 5, "a")) + if err != nil { + t.Fatalf("Intend user A: %v", err) + } + second := testAction([]byte{2}, 2, "a") + second.ActionID = "action-other-account" + second.Sender = other + if _, err := store.Intend(second); err != nil { + t.Fatalf("Intend user B: %v", err) + } + maximum, found, err := store.MaxActionNonce(3133701, testSender) + if err != nil { + t.Fatalf("MaxActionNonce: %v", err) + } + if !found || maximum != 5 { + t.Fatalf("MaxActionNonce(A) = %d, %v; want 5, true", maximum, found) + } + maximum, found, err = store.MaxActionNonce(3133701, other) + if err != nil { + t.Fatalf("MaxActionNonce: %v", err) + } + if !found || maximum != 2 { + t.Fatalf("MaxActionNonce(B) = %d, %v; want 2, true", maximum, found) + } + if _, found, err = store.MaxActionNonce(3133701, "0x00000000000000000000000000000000000000f9"); err != nil { + t.Fatalf("MaxActionNonce: %v", err) + } else if found { + t.Fatal("an account with no actions must not report a nonce floor") + } + if _, found, err = store.MaxActionNonce(999, testSender); err != nil { + t.Fatalf("MaxActionNonce: %v", err) + } else if found { + t.Fatal("an unknown chain must not report a nonce floor") + } + if _, _, err := store.MaxActionNonce(3133701, ""); err == nil { + t.Fatal("a nonce floor without an account must be refused") + } + // Two accounts may hold the same nonce on one chain; one account may not. + third := testAction([]byte{3}, 5, "a") + third.ActionID = "action-other-account-same-nonce" + third.Sender = other + if _, err := store.Intend(third); err != nil { + t.Fatalf("a second account must be able to use the same nonce: %v", err) + } + clash := testAction([]byte{4}, 5, "a") + clash.ActionID = "action-same-account-clash" + if _, err := store.Intend(clash); err == nil { + t.Fatalf("account %s used nonce 5 twice", first.Sender) + } +} + +func TestRecordSignedRecoversAfterReopen(t *testing.T) { + directory := filepath.Join(t.TempDir(), "runner") + path := filepath.Join(directory, "runner.sqlite") + store, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + action := testAction([]byte{0xde, 0xad}, 11, "a") + if _, err := store.Intend(action); err != nil { + t.Fatalf("Intend: %v", err) + } + raw := []byte{0x02, 0xf8, 0x70, 0x01, 0x02} + transactionHash := keccakHexOf(t, raw) + if err := store.RecordSigned(action.ActionID, raw, transactionHash); err != nil { + t.Fatalf("RecordSigned: %v", err) + } + // The durable signed row and the stage boundary are written in the same + // transaction as the action update. + var rawSHA256 string + var storedRaw []byte + if err := store.db.QueryRow( + `SELECT raw_sha256, raw_transaction FROM durable_signed_transactions + WHERE attempt_id = ? AND stage = ?`, action.AttemptID, action.Stage, + ).Scan(&rawSHA256, &storedRaw); err != nil { + t.Fatalf("durable signed row: %v", err) + } + if rawSHA256 != sha256Hex(raw) || hexOf(storedRaw) != hexOf(raw) { + t.Fatalf("durable signed row = %s, %s", rawSHA256, hexOf(storedRaw)) + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopened, err := Open(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + recovered, err := reopened.Action(action.ActionID) + if err != nil { + t.Fatalf("Action after reopen: %v", err) + } + if recovered == nil { + t.Fatal("the action row disappeared across the reopen") + } + if recovered.State != ActionSigned || recovered.TransactionHash != transactionHash || + recovered.RawTransactionHex != hexOf(raw) || recovered.Nonce != 11 || + recovered.CalldataSHA256 != action.CalldataSHA256 { + t.Fatalf("recovered action = %+v", recovered) + } + if err := VerifyActionIdentity(recovered); err != nil { + t.Fatalf("VerifyActionIdentity: %v", err) + } + // Replaying the identical signed bytes is a no-op; different bytes are drift. + if err := reopened.RecordSigned(action.ActionID, raw, transactionHash); err != nil { + t.Fatalf("RecordSigned replay: %v", err) + } + other := []byte{0x02, 0xf8, 0x70, 0x01, 0x03} + if err := reopened.RecordSigned(action.ActionID, other, keccakHexOf(t, other)); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted raw bytes error = %v, want ErrDrift", err) + } + if err := reopened.RecordSigned(action.ActionID, raw, "0x"+strings.Repeat("11", 32)); !errors.Is(err, ErrDrift) { + t.Fatalf("hash/bytes mismatch error = %v, want ErrDrift", err) + } + pending, err := reopened.PendingActions() + if err != nil { + t.Fatalf("PendingActions: %v", err) + } + if len(pending) != 1 || pending[0].ActionID != action.ActionID { + t.Fatalf("PendingActions = %+v, want the signed action", pending) + } +} + +func TestMutatedActionRowIsRejectedAsDrift(t *testing.T) { + store := openTestStore(t) + action := testAction([]byte{0xaa, 0xbb}, 4, "a") + if _, err := store.Intend(action); err != nil { + t.Fatalf("Intend: %v", err) + } + raw := []byte{0x02, 0xf8, 0x6c, 0x01} + transactionHash := keccakHexOf(t, raw) + if err := store.RecordSigned(action.ActionID, raw, transactionHash); err != nil { + t.Fatalf("RecordSigned: %v", err) + } + mutations := []struct { + name string + statement string + }{ + {"calldata_sha256", "UPDATE actions SET calldata_sha256 = ? WHERE action_id = ?"}, + {"transaction_hash", "UPDATE actions SET transaction_hash = ? WHERE action_id = ?"}, + {"raw_transaction_hex", "UPDATE actions SET raw_transaction_hex = ? WHERE action_id = ?"}, + {"calldata_bytes", "UPDATE actions SET calldata_bytes = 99 WHERE action_id = ?"}, + } + // Gas, nonce, value and the fee caps are frozen against the signed bytes + // rather than the calldata, so they are re-derived from the raw transaction + // by the transactor (evm.Transactor.validateSignedBytes). + for _, mutation := range mutations { + stored, err := store.Action(action.ActionID) + if err != nil { + t.Fatalf("%s: Action: %v", mutation.name, err) + } + if err := VerifyActionIdentity(stored); err != nil { + t.Fatalf("%s: the untouched row must verify: %v", mutation.name, err) + } + applyMutation(t, store, mutation.statement, mutation.name, action.ActionID) + mutated, err := store.Action(action.ActionID) + if err != nil { + t.Fatalf("%s: Action after mutation: %v", mutation.name, err) + } + if err := VerifyActionIdentity(mutated); !errors.Is(err, ErrDrift) { + t.Fatalf("%s: mutated row error = %v, want ErrDrift", mutation.name, err) + } + restoreAction(t, store, stored) + } +} + +// applyMutation edits one column behind the store's back, the way a corrupted +// spill file or a stray manual edit would. +func applyMutation(t *testing.T, store *Store, statement, name, actionID string) { + t.Helper() + var err error + switch name { + case "calldata_sha256", "transaction_hash", "raw_transaction_hex": + _, err = store.db.Exec(statement, strings.Repeat("ff", 32), actionID) + default: + _, err = store.db.Exec(statement, actionID) + } + if err != nil { + t.Fatalf("mutation %s: %v", name, err) + } +} + +func restoreAction(t *testing.T, store *Store, action *Action) { + t.Helper() + if _, err := store.db.Exec( + `UPDATE actions SET calldata_bytes = ?, calldata_sha256 = ?, raw_transaction_hex = ?, + transaction_hash = ?, gas = ? WHERE action_id = ?`, + action.CalldataBytes, action.CalldataSHA256, action.RawTransactionHex, + action.TransactionHash, int64(action.Gas), action.ActionID, + ); err != nil { + t.Fatalf("restore: %v", err) + } +} + +func TestObserveAndSucceedStateMachine(t *testing.T) { + store := openTestStore(t) + action := testAction([]byte{0x01}, 5, "a") + if _, err := store.Intend(action); err != nil { + t.Fatalf("Intend: %v", err) + } + if err := store.Observe(action.ActionID, ActionSubmitted, nil); err == nil { + t.Fatal("an action without signed bytes must not become submitted") + } + raw := []byte{0x02, 0xf8, 0x68, 0x01} + transactionHash := keccakHexOf(t, raw) + if err := store.RecordSigned(action.ActionID, raw, transactionHash); err != nil { + t.Fatalf("RecordSigned: %v", err) + } + if err := store.Observe(action.ActionID, ActionSubmitted, map[string]any{"rpc": "accepted"}); err != nil { + t.Fatalf("Observe submitted: %v", err) + } + receiptDigest := sha256Hex([]byte("receipt")) + result := ActionResult{ + TransactionHash: transactionHash, + BlockNumber: 12, + GasUsed: 51_000, + ReceiptPath: filepath.Join(t.TempDir(), "receipt.json"), + ReceiptSHA256: receiptDigest, + Detail: map[string]any{"gas_used": 51_000}, + } + if err := store.Succeed(action.ActionID, result); err != nil { + t.Fatalf("Succeed: %v", err) + } + succeeded, err := store.Action(action.ActionID) + if err != nil { + t.Fatalf("Action: %v", err) + } + if succeeded.State != ActionSucceeded || succeeded.BlockNumber != 12 || succeeded.GasUsed != 51_000 || + succeeded.ReceiptSHA256 != receiptDigest { + t.Fatalf("succeeded action = %+v", succeeded) + } + detail, err := succeeded.Detail() + if err != nil { + t.Fatalf("Detail: %v", err) + } + if detail["rpc"] != "accepted" { + t.Fatalf("merged detail lost the observed field: %#v", detail) + } + if number, ok := detail["gas_used"].(json.Number); !ok || number.String() != "51000" { + t.Fatalf("merged detail gas_used = %#v", detail["gas_used"]) + } + if err := store.Succeed(action.ActionID, result); err != nil { + t.Fatalf("Succeed replay: %v", err) + } + drifted := result + drifted.TransactionHash = "0x" + strings.Repeat("22", 32) + if err := store.Succeed(action.ActionID, drifted); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted transaction error = %v, want ErrDrift", err) + } + other := result + other.TransactionHash = transactionHash + other.BlockNumber = 99 + if err := store.Succeed(action.ActionID, other); !errors.Is(err, ErrDrift) { + t.Fatalf("drifted block error = %v, want ErrDrift", err) + } + if err := store.Observe(action.ActionID, ActionSubmitted, nil); !errors.Is(err, ErrStateTransition) { + t.Fatalf("regressing a succeeded action error = %v, want ErrStateTransition", err) + } + missingDigest := result + missingDigest.ReceiptSHA256 = "" + if err := store.Succeed(action.ActionID, missingDigest); err == nil { + t.Fatal("a receipt path without its digest must be rejected") + } + // A failed action is terminal. + failing := testAction([]byte{0x02}, 6, "a") + failing.ActionID = "action-failing" + if _, err := store.Intend(failing); err != nil { + t.Fatalf("Intend failing: %v", err) + } + if err := store.Observe(failing.ActionID, ActionFailed, map[string]any{"error_class": "Reverted"}); err != nil { + t.Fatalf("Observe failed: %v", err) + } + if err := store.Observe(failing.ActionID, ActionFailed, nil); err != nil { + t.Fatalf("Observe failed replay: %v", err) + } +} diff --git a/go-runtime/internal/xir/abi.go b/go-runtime/internal/xir/abi.go new file mode 100644 index 0000000..92a453a --- /dev/null +++ b/go-runtime/internal/xir/abi.go @@ -0,0 +1,158 @@ +package xir + +import ( + "crypto/sha256" + "fmt" +) + +// TypedIDABI is the ABI tuple shape of XIRTypes.TypedId. +type TypedIDABI struct { + Kind uint8 `abi:"kind"` + Value []byte `abi:"value"` +} + +// RecordABI is the ABI tuple shape of XIRTypes.Record. +type RecordABI struct { + SourceGateway TypedIDABI `abi:"sourceGateway"` + SourceApp TypedIDABI `abi:"sourceApp"` + DestinationApp TypedIDABI `abi:"destinationApp"` + Nonce uint64 `abi:"nonce"` + PayloadHash [32]byte `abi:"payloadHash"` +} + +// ContextABI is the ABI tuple shape of XIRTypes.VerifiedContext. +type ContextABI struct { + RequiredSecurity uint8 `abi:"requiredSecurity"` + PolicyHash [32]byte `abi:"policyHash"` +} + +// CertificateABI is the ABI tuple shape of XIRTypes.RootCertificate. +type CertificateABI struct { + RegistryVersion uint32 `abi:"registryVersion"` + Signature []byte `abi:"signature"` +} + +// ReceiptABI is the ABI tuple shape of XIRTypes.Receipt. The prefix is the +// final component, matching XIRTypes.sol and xir_trace.receipt_tuple. +type ReceiptABI struct { + SourceGateway TypedIDABI `abi:"srcGateway"` + DestinationGateway TypedIDABI `abi:"dstGateway"` + ProfileHash [32]byte `abi:"profileHash"` + EvidenceHash [32]byte `abi:"evidenceHash"` + TransitionHash [32]byte `abi:"transitionHash"` + PriorPrefix [32]byte `abi:"priorPrefix"` +} + +// EnvelopeABI is the ABI tuple shape of XIRTypes.Envelope. +type EnvelopeABI struct { + Record RecordABI `abi:"record"` + Context ContextABI `abi:"context"` + Certificate CertificateABI `abi:"certificate"` + Receipts []ReceiptABI `abi:"receipts"` +} + +// MultihopPayloadABI is the ABI tuple shape of NativeMultihopPayload.Data. +type MultihopPayloadABI struct { + AttemptID [32]byte `abi:"attemptId"` + Route []byte `abi:"route"` + RouteSequence uint64 `abi:"routeSequence"` + ApplicationPayload []byte `abi:"applicationPayload"` +} + +// ABI returns the ABI shape of one typed identifier. +func (t TypedID) ABI() TypedIDABI { + return TypedIDABI{Kind: t.Kind, Value: append([]byte(nil), t.Value...)} +} + +// ABI returns the ABI shape of the record. +func (r Record) ABI() RecordABI { + return RecordABI{ + SourceGateway: r.SourceGateway.ABI(), + SourceApp: r.SourceApp.ABI(), + DestinationApp: r.DestinationApp.ABI(), + Nonce: r.Nonce, + PayloadHash: r.PayloadHash, + } +} + +// ABI returns the ABI shape of the context. +func (c Context) ABI() ContextABI { + return ContextABI{RequiredSecurity: c.RequiredSecurity, PolicyHash: c.PolicyHash} +} + +// ABI returns the ABI shape of one receipt. +func (r Receipt) ABI() ReceiptABI { + return ReceiptABI{ + SourceGateway: r.SourceGateway.ABI(), + DestinationGateway: r.DestinationGateway.ABI(), + ProfileHash: r.ProfileHash, + EvidenceHash: r.EvidenceHash, + TransitionHash: r.TransitionHash, + PriorPrefix: r.PriorPrefix, + } +} + +// ABI returns the ABI shape of the envelope as the XIR gateway expects it. +func (e Envelope) ABI() EnvelopeABI { + receipts := make([]ReceiptABI, 0, len(e.Receipts)) + for _, receipt := range e.Receipts { + receipts = append(receipts, receipt.ABI()) + } + return EnvelopeABI{ + Record: e.Record.ABI(), + Context: e.Context.ABI(), + Certificate: CertificateABI{ + RegistryVersion: e.RegistryVersion, + Signature: append([]byte(nil), e.Signature...), + }, + Receipts: receipts, + } +} + +// PayloadSchedule is the application payload size schedule of a campaign +// profile (configs/native/native-multihop-switching-v1.json payload_schedule). +type PayloadSchedule struct { + MinimumBytes uint64 + SizeBucketCount uint64 + SizeStepBytes uint64 +} + +// Size returns the application payload size of one route sequence. +func (s PayloadSchedule) Size(routeSequence uint64) (uint64, error) { + if s.SizeBucketCount == 0 { + return 0, fmt.Errorf("payload schedule bucket count is zero") + } + return s.MinimumBytes + (routeSequence%s.SizeBucketCount)*s.SizeStepBytes, nil +} + +// ApplicationBytes expands the deterministic application payload of one +// attempt (src/xir_lab/native/multihop_runner.py _application_payload). +func ApplicationBytes(fixedSeed, phase string, routeSequence uint64, schedule PayloadSchedule) ([]byte, error) { + size, err := schedule.Size(routeSequence) + if err != nil { + return nil, err + } + material := []byte(fmt.Sprintf("xir-multihop-v1:%s:%s:%d", fixedSeed, phase, routeSequence)) + application := make([]byte, 0, size+32) + counter := uint32(0) + for uint64(len(application)) < size { + counterBytes := []byte{byte(counter >> 24), byte(counter >> 16), byte(counter >> 8), byte(counter)} + digest := sha256.Sum256(append(append([]byte(nil), material...), counterBytes...)) + application = append(application, digest[:]...) + counter++ + } + return application[:size], nil +} + +// ApplicationPayload builds the NativeMultihopPayload.Data tuple of one +// attempt, matching the Python runner's application payload encoding. +func ApplicationPayload( + attemptID [32]byte, route string, routeSequence uint64, application []byte, +) MultihopPayloadABI { + return MultihopPayloadABI{ + AttemptID: attemptID, + Route: []byte(route), + RouteSequence: routeSequence, + ApplicationPayload: append([]byte(nil), application...), + } +} diff --git a/go-runtime/internal/xir/vectors_test.go b/go-runtime/internal/xir/vectors_test.go new file mode 100644 index 0000000..3a4f5d1 --- /dev/null +++ b/go-runtime/internal/xir/vectors_test.go @@ -0,0 +1,392 @@ +package xir + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/lysrain21/XIR/go-runtime/internal/abiutil" +) + +type vectorDocument struct { + SchemaVersion string `json:"schema_version"` + Constants struct { + RegistryVersion uint32 `json:"registry_version"` + PayloadSchedule struct { + MinimumBytes uint64 `json:"minimum_bytes"` + SizeBucketCount uint64 `json:"size_bucket_count"` + SizeStepBytes uint64 `json:"size_step_bytes"` + } `json:"payload_schedule"` + FixedSeed string `json:"fixed_seed"` + FixtureKeys map[string]string `json:"fixture_keys"` + ChainIDs []uint64 `json:"chain_ids"` + Routes []string `json:"routes"` + } `json:"constants"` + ProfileHashes []struct { + Route string `json:"route"` + HopIndex int `json:"hop_index"` + Hash string `json:"hash"` + } `json:"profile_hashes"` + GatewayTypedIDs []struct { + ChainID uint64 `json:"chain_id"` + Kind uint8 `json:"kind"` + Value string `json:"value"` + } `json:"gateway_typed_ids"` + AdapterKeys []struct { + Route string `json:"route"` + HopIndex int `json:"hop_index"` + Direction string `json:"direction"` + Key string `json:"key"` + } `json:"adapter_keys"` + TypedIDs []struct { + Kind uint8 `json:"kind"` + Value string `json:"value"` + Hash string `json:"hash"` + } `json:"typed_ids"` + BundleSteps []struct { + Index int `json:"index"` + ReceiptCount int `json:"receipt_count"` + BundleStart string `json:"bundle_start"` + Step string `json:"step"` + ProfileHash string `json:"profile_hash"` + EvidenceHash string `json:"evidence_hash"` + TransitionHash string `json:"transition_hash"` + } `json:"bundle_steps"` + Payloads []struct { + AttemptID string `json:"attempt_id"` + Phase string `json:"phase"` + Route string `json:"route"` + RouteSequence uint64 `json:"route_sequence"` + AttemptKey string `json:"attempt_key"` + ApplicationSHA256 string `json:"application_sha256"` + ApplicationBytes string `json:"application_bytes"` + PayloadEncoding string `json:"payload_encoding"` + } `json:"payloads"` + Envelopes []struct { + Seed int `json:"seed"` + Record struct { + SourceGateway string `json:"source_gateway"` + SourceApp string `json:"source_app"` + DestinationApp string `json:"destination_app"` + Nonce uint64 `json:"nonce"` + PayloadHash string `json:"payload_hash"` + } `json:"record"` + Context struct { + RequiredSecurity uint8 `json:"required_security"` + PolicyHash string `json:"policy_hash"` + } `json:"context"` + RID string `json:"rid"` + MID string `json:"mid"` + RootPrefix string `json:"root_prefix"` + TransitionHashes []string `json:"transition_hashes"` + Receipts []struct { + SourceGateway string `json:"source_gateway"` + DestinationGateway string `json:"destination_gateway"` + ProfileHash string `json:"profile_hash"` + EvidenceHash string `json:"evidence_hash"` + TransitionHash string `json:"transition_hash"` + PriorPrefix string `json:"prior_prefix"` + ReceiptHash string `json:"receipt_hash"` + NextPrefix string `json:"next_prefix"` + } `json:"receipts"` + BundleCommitment string `json:"bundle_commitment"` + RootSignature string `json:"root_signature"` + EnvelopeEncoding string `json:"envelope_encoding"` + RecordHash string `json:"record_hash"` + ContextHash string `json:"context_hash"` + } `json:"envelopes"` +} + +// payloadArgumentJSON and envelopeArgumentJSON restate the Solidity tuple +// types of NativeMultihopPayload.Data and XIRTypes.Envelope. The vector file +// pins the bytes both references produce, so a shape error fails the test. +const payloadArgumentJSON = `{"name":"payload","type":"tuple","components":[` + + `{"name":"attemptId","type":"bytes32"},{"name":"route","type":"bytes"},` + + `{"name":"routeSequence","type":"uint64"},{"name":"applicationPayload","type":"bytes"}]}` + +const typedIDComponents = `{"name":"kind","type":"uint8"},{"name":"value","type":"bytes"}` + +const envelopeArgumentJSON = `{"name":"envelope","type":"tuple","components":[` + + `{"name":"record","type":"tuple","components":[` + + `{"name":"sourceGateway","type":"tuple","components":[` + typedIDComponents + `]},` + + `{"name":"sourceApp","type":"tuple","components":[` + typedIDComponents + `]},` + + `{"name":"destinationApp","type":"tuple","components":[` + typedIDComponents + `]},` + + `{"name":"nonce","type":"uint64"},{"name":"payloadHash","type":"bytes32"}]},` + + `{"name":"context","type":"tuple","components":[` + + `{"name":"requiredSecurity","type":"uint8"},{"name":"policyHash","type":"bytes32"}]},` + + `{"name":"certificate","type":"tuple","components":[` + + `{"name":"registryVersion","type":"uint32"},{"name":"signature","type":"bytes"}]},` + + `{"name":"receipts","type":"tuple[]","components":[` + + `{"name":"srcGateway","type":"tuple","components":[` + typedIDComponents + `]},` + + `{"name":"dstGateway","type":"tuple","components":[` + typedIDComponents + `]},` + + `{"name":"profileHash","type":"bytes32"},{"name":"evidenceHash","type":"bytes32"},` + + `{"name":"transitionHash","type":"bytes32"},{"name":"priorPrefix","type":"bytes32"}]}]}` + +func loadVectors(t *testing.T) vectorDocument { + t.Helper() + path := filepath.Join("..", "..", "testdata", "vectors.json") + payload, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read parity vectors: %v", err) + } + var document vectorDocument + if err := json.Unmarshal(payload, &document); err != nil { + t.Fatalf("decode parity vectors: %v", err) + } + if document.SchemaVersion != "xir-go-parity-vectors-v1" { + t.Fatalf("unexpected vector schema: %q", document.SchemaVersion) + } + return document +} + +func digest(t *testing.T, value string) [32]byte { + t.Helper() + raw, err := hex.DecodeString(trimHex(value)) + if err != nil { + t.Fatalf("decode digest %q: %v", value, err) + } + if len(raw) != 32 { + t.Fatalf("digest %q is %d bytes", value, len(raw)) + } + var out [32]byte + copy(out[:], raw) + return out +} + +func trimHex(value string) string { + if len(value) >= 2 && value[:2] == "0x" { + return value[2:] + } + return value +} + +func TestTypedIDVectors(t *testing.T) { + document := loadVectors(t) + for _, vector := range document.TypedIDs { + value, err := hex.DecodeString(trimHex(vector.Value)) + if err != nil { + t.Fatalf("decode typed id value: %v", err) + } + id := TypedID{Kind: vector.Kind, Value: value} + encoded, err := id.Bytes() + if err != nil { + t.Fatalf("encode typed id: %v", err) + } + wantEncoded := append([]byte{vector.Kind, byte(len(value))}, value...) + if string(encoded) != string(wantEncoded) { + t.Fatalf("typed id encoding mismatch: %x != %x", encoded, wantEncoded) + } + hash, err := id.Hash() + if err != nil { + t.Fatalf("hash typed id: %v", err) + } + if hash != digest(t, vector.Hash) { + t.Fatalf("typed id hash mismatch for %s", vector.Value) + } + } +} + +func TestProfileAndDeploymentIdentityVectors(t *testing.T) { + document := loadVectors(t) + for _, vector := range document.ProfileHashes { + hash, err := ProfileHash(vector.Route, vector.HopIndex) + if err != nil { + t.Fatalf("profile hash: %v", err) + } + if hash != digest(t, vector.Hash) { + t.Fatalf("profile hash mismatch for %s hop %d", vector.Route, vector.HopIndex) + } + } + for _, vector := range document.GatewayTypedIDs { + id, err := GatewayTypedID(vector.ChainID) + if err != nil { + t.Fatalf("gateway typed id: %v", err) + } + if id.Kind != vector.Kind || hex.EncodeToString(id.Value) != trimHex(vector.Value) { + t.Fatalf("gateway typed id mismatch for chain %d", vector.ChainID) + } + } + for _, vector := range document.AdapterKeys { + key, err := AdapterKey(vector.Route, vector.HopIndex, vector.Direction) + if err != nil { + t.Fatalf("adapter key: %v", err) + } + if key != vector.Key { + t.Fatalf("adapter key mismatch: %q != %q", key, vector.Key) + } + } +} + +func TestApplicationPayloadVectors(t *testing.T) { + document := loadVectors(t) + schedule := PayloadSchedule{ + MinimumBytes: document.Constants.PayloadSchedule.MinimumBytes, + SizeBucketCount: document.Constants.PayloadSchedule.SizeBucketCount, + SizeStepBytes: document.Constants.PayloadSchedule.SizeStepBytes, + } + for _, vector := range document.Payloads { + application, err := ApplicationBytes( + document.Constants.FixedSeed, vector.Phase, vector.RouteSequence, schedule, + ) + if err != nil { + t.Fatalf("application bytes: %v", err) + } + if hex.EncodeToString(application) != trimHex(vector.ApplicationBytes) { + t.Fatalf("application payload mismatch for %s", vector.AttemptID) + } + attemptKey := Keccak256([]byte(vector.AttemptID)) + if digest(t, vector.AttemptKey) != attemptKey { + t.Fatalf("attempt key mismatch for %s", vector.AttemptID) + } + payload := ApplicationPayload(attemptKey, vector.Route, vector.RouteSequence, application) + encoded, err := abiutil.PackArgument(payloadArgumentJSON, payload) + if err != nil { + t.Fatalf("pack multihop payload: %v", err) + } + if hex.EncodeToString(encoded) != trimHex(vector.PayloadEncoding) { + t.Fatalf("multihop payload encoding mismatch for %s", vector.AttemptID) + } + } +} + +func TestEnvelopeVectors(t *testing.T) { + document := loadVectors(t) + for _, vector := range document.Envelopes { + record := Record{ + SourceGateway: TypedID{Kind: KindEVM, Value: mustHex(t, vector.Record.SourceGateway)}, + SourceApp: TypedID{Kind: KindEVM, Value: mustHex(t, vector.Record.SourceApp)}, + DestinationApp: TypedID{Kind: KindEVM, Value: mustHex(t, vector.Record.DestinationApp)}, + Nonce: vector.Record.Nonce, + PayloadHash: digest(t, vector.Record.PayloadHash), + } + context := Context{ + RequiredSecurity: vector.Context.RequiredSecurity, + PolicyHash: digest(t, vector.Context.PolicyHash), + } + recordHash, err := record.Hash() + if err != nil { + t.Fatalf("record hash: %v", err) + } + if recordHash != digest(t, vector.RecordHash) { + t.Fatalf("record hash mismatch: %s", FormatDigest(recordHash)) + } + if context.Hash() != digest(t, vector.ContextHash) { + t.Fatalf("context hash mismatch") + } + rid, err := RootID(record, context, document.Constants.RegistryVersion) + if err != nil { + t.Fatalf("root id: %v", err) + } + if rid != digest(t, vector.RID) { + t.Fatalf("rid mismatch: %s != %s", FormatDigest(rid), vector.RID) + } + mid, err := MessageID(rid, record.DestinationApp) + if err != nil { + t.Fatalf("message id: %v", err) + } + if mid != digest(t, vector.MID) { + t.Fatalf("mid mismatch: %s != %s", FormatDigest(mid), vector.MID) + } + if RootPrefix(rid) != digest(t, vector.RootPrefix) { + t.Fatalf("root prefix mismatch") + } + receipts := make([]Receipt, 0, len(vector.Receipts)) + for index, item := range vector.Receipts { + receipt := Receipt{ + SourceGateway: TypedID{Kind: KindEVM, Value: mustHex(t, item.SourceGateway)}, + DestinationGateway: TypedID{Kind: KindEVM, Value: mustHex(t, item.DestinationGateway)}, + ProfileHash: digest(t, item.ProfileHash), + EvidenceHash: digest(t, item.EvidenceHash), + TransitionHash: digest(t, item.TransitionHash), + PriorPrefix: digest(t, item.PriorPrefix), + } + if len(receipts) == 0 { + if receipt.PriorPrefix != RootPrefix(rid) { + t.Fatalf("receipt %d prefix is not the root prefix", index) + } + } else { + prior, err := NextPrefix(receipts[len(receipts)-1].PriorPrefix, receipts[len(receipts)-1]) + if err != nil { + t.Fatalf("next prefix: %v", err) + } + if receipt.PriorPrefix != prior { + t.Fatalf("receipt %d prefix does not extend its predecessor", index) + } + } + transition, err := TransitionHash(record, context, receipt.SourceGateway, receipt.DestinationGateway) + if err != nil { + t.Fatalf("transition hash: %v", err) + } + if transition != digest(t, vector.TransitionHashes[index]) { + t.Fatalf("transition hash mismatch at %d", index) + } + if transition != receipt.TransitionHash { + t.Fatalf("receipt %d transition differs from derivation", index) + } + receiptHash, err := receipt.Hash() + if err != nil { + t.Fatalf("receipt hash: %v", err) + } + if receiptHash != digest(t, item.ReceiptHash) { + t.Fatalf("receipt hash mismatch at %d", index) + } + next, _ := NextPrefix(receipt.PriorPrefix, receipt) + if next != digest(t, item.NextPrefix) { + t.Fatalf("next prefix mismatch at %d", index) + } + receipts = append(receipts, receipt) + } + if BundleCommitment(receipts) != digest(t, vector.BundleCommitment) { + t.Fatalf("bundle commitment mismatch") + } + envelope := Envelope{ + Record: record, + Context: context, + RegistryVersion: document.Constants.RegistryVersion, + Signature: mustHex(t, vector.RootSignature), + Receipts: receipts, + } + if len(envelope.Signature) != 65 { + t.Fatalf("root signature is %d bytes", len(envelope.Signature)) + } + encoded, err := abiutil.PackArgument(envelopeArgumentJSON, envelope.ABI()) + if err != nil { + t.Fatalf("pack envelope: %v", err) + } + if hex.EncodeToString(encoded) != trimHex(vector.EnvelopeEncoding) { + t.Fatalf("envelope ABI encoding mismatch for seed %d", vector.Seed) + } + } +} + +func TestBundleStepVectors(t *testing.T) { + document := loadVectors(t) + if len(document.BundleSteps) == 0 { + t.Fatal("bundle step vectors are empty") + } + for _, vector := range document.BundleSteps { + receipt := Receipt{ + ProfileHash: digest(t, vector.ProfileHash), + EvidenceHash: digest(t, vector.EvidenceHash), + TransitionHash: digest(t, vector.TransitionHash), + } + if BundleStart(vector.ReceiptCount) != digest(t, vector.BundleStart) { + t.Fatalf("bundle start mismatch at %d", vector.Index) + } + step := BundleStep(BundleStart(vector.ReceiptCount), vector.Index, receipt) + if step != digest(t, vector.Step) { + t.Fatalf("bundle step mismatch at %d", vector.Index) + } + } +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + raw, err := hex.DecodeString(trimHex(value)) + if err != nil { + t.Fatalf("decode %q: %v", value, err) + } + return raw +} diff --git a/go-runtime/internal/xir/xir.go b/go-runtime/internal/xir/xir.go new file mode 100644 index 0000000..c7163e5 --- /dev/null +++ b/go-runtime/internal/xir/xir.go @@ -0,0 +1,362 @@ +// Package xir implements the XIR wire encoding of the contracts in +// contracts/src/XIREncoding.sol and of the Python reference runtime in +// src/xir_lab/native/xir_trace.py. +// +// Every derivation in this package is byte exact with both references; the +// parity test in vectors_test.go compares it against vectors generated by +// scripts/generate_go_parity_vectors.py. +package xir + +import ( + "encoding/hex" + "fmt" + + "golang.org/x/crypto/sha3" +) + +// RegistryVersion is the root-registry version used by every campaign profile +// (src/xir_lab/native/multihop_deployer.py REGISTRY_VERSION). +const RegistryVersion uint32 = 1 + +// Typed identifier kinds (contracts/src/XIRTypes.sol). +const ( + KindEVM uint8 = 1 + KindSolana uint8 = 2 +) + +var ( + tagRecord = []byte("XIR_RECORD_V1") + tagContext = []byte("XIR_CONTEXT_V1") + tagRID = []byte("XIR_RID_V1") + tagMID = []byte("XIR_MID_V1") + tagRoot = []byte("XIR_ROOT_V1") + tagTransition = []byte("XIR_TRANSITION_V1") + tagHop = []byte("XIR_HOP_V1") + tagPrefix = []byte("XIR_PREFIX_V1") + tagBundle = []byte("XIR_BUNDLE_V1") + tagBundleStep = []byte("XIR_BUNDLE_STEP_V1") + tagGatewayID = []byte("XIR_NATIVE_MULTIHOP_GATEWAY_ID_V1") + tagMultihopProf = []byte("XIR_NATIVE_MULTIHOP_PROFILE_V1") +) + +// Keccak256 hashes the concatenation of the given byte slices. +func Keccak256(parts ...[]byte) [32]byte { + hasher := sha3.NewLegacyKeccak256() + for _, part := range parts { + _, _ = hasher.Write(part) + } + var digest [32]byte + hasher.Sum(digest[:0]) + return digest +} + +// TypedID is a kind-prefixed chain-scoped identifier. +type TypedID struct { + Kind uint8 + Value []byte +} + +// NewEVMID builds an EVM-kind typed identifier from a 20-byte address. +func NewEVMID(value []byte) (TypedID, error) { + id := TypedID{Kind: KindEVM, Value: append([]byte(nil), value...)} + if _, err := id.Bytes(); err != nil { + return TypedID{}, err + } + return id, nil +} + +// Bytes returns kind || length || value, matching XIREncoding.encodeTypedId. +func (t TypedID) Bytes() ([]byte, error) { + expected := 0 + switch t.Kind { + case KindEVM: + expected = 20 + case KindSolana: + expected = 32 + } + if expected == 0 || len(t.Value) != expected { + return nil, fmt.Errorf( + "invalid XIR typed identifier: kind=%d length=%d", t.Kind, len(t.Value), + ) + } + out := make([]byte, 0, 2+len(t.Value)) + out = append(out, t.Kind, byte(len(t.Value))) + return append(out, t.Value...), nil +} + +// Hash returns keccak256 of the encoded identifier (XIREncoding.typedIdHash). +func (t TypedID) Hash() ([32]byte, error) { + encoded, err := t.Bytes() + if err != nil { + return [32]byte{}, err + } + return Keccak256(encoded), nil +} + +// EVMAddress returns the 20-byte address of an EVM-kind identifier. +func (t TypedID) EVMAddress() ([]byte, error) { + if t.Kind != KindEVM || len(t.Value) != 20 { + return nil, fmt.Errorf("identifier is not an EVM address: kind=%d", t.Kind) + } + return append([]byte(nil), t.Value...), nil +} + +// Record is the application record carried across every hop. +type Record struct { + SourceGateway TypedID + SourceApp TypedID + DestinationApp TypedID + Nonce uint64 + PayloadHash [32]byte +} + +// Context carries the required security level and policy commitment. +type Context struct { + RequiredSecurity uint8 + PolicyHash [32]byte +} + +// Receipt is one verified hop of the trace. +type Receipt struct { + SourceGateway TypedID + DestinationGateway TypedID + ProfileHash [32]byte + EvidenceHash [32]byte + TransitionHash [32]byte + PriorPrefix [32]byte +} + +// Envelope is the delivered proof bundle. +type Envelope struct { + Record Record + Context Context + RegistryVersion uint32 + Signature []byte + Receipts []Receipt +} + +// Hash returns the record digest (XIREncoding.recordHash). +func (r Record) Hash() ([32]byte, error) { + parts := make([][]byte, 0, 6) + parts = append(parts, tagRecord) + for _, id := range []TypedID{r.SourceGateway, r.SourceApp, r.DestinationApp} { + encoded, err := id.Bytes() + if err != nil { + return [32]byte{}, err + } + parts = append(parts, encoded) + } + parts = append(parts, uint64Bytes(r.Nonce), r.PayloadHash[:]) + return Keccak256(parts...), nil +} + +// Hash returns the context digest (XIREncoding.contextHash). +func (c Context) Hash() [32]byte { + return Keccak256(tagContext, []byte{c.RequiredSecurity}, c.PolicyHash[:]) +} + +// Hash returns the receipt digest (XIREncoding.receiptHash). +func (r Receipt) Hash() ([32]byte, error) { + src, err := r.SourceGateway.Bytes() + if err != nil { + return [32]byte{}, err + } + dst, err := r.DestinationGateway.Bytes() + if err != nil { + return [32]byte{}, err + } + return Keccak256( + tagHop, + r.PriorPrefix[:], + src, + dst, + r.ProfileHash[:], + r.EvidenceHash[:], + r.TransitionHash[:], + ), nil +} + +// RootID returns the root identifier over record, context, and registry version +// (XIREncoding.rootId). +func RootID(record Record, context Context, registryVersion uint32) ([32]byte, error) { + recordDigest, err := record.Hash() + if err != nil { + return [32]byte{}, err + } + contextDigest := context.Hash() + id, err := record.SourceGateway.Bytes() + if err != nil { + return [32]byte{}, err + } + return Keccak256(tagRID, id, recordDigest[:], contextDigest[:], uint32Bytes(registryVersion)), nil +} + +// MessageID returns the delivery identifier for a root and destination app +// (XIREncoding.messageId). +func MessageID(rid [32]byte, destinationApp TypedID) ([32]byte, error) { + id, err := destinationApp.Bytes() + if err != nil { + return [32]byte{}, err + } + return Keccak256(tagMID, rid[:], id), nil +} + +// RootPrefix returns the empty-trace prefix of a root (XIREncoding.rootPrefix). +func RootPrefix(rid [32]byte) [32]byte { + return Keccak256(tagRoot, rid[:]) +} + +// TransitionHash returns the transition digest of one hop +// (XIREncoding.transitionHash). +func TransitionHash(record Record, context Context, source, destination TypedID) ([32]byte, error) { + recordDigest, err := record.Hash() + if err != nil { + return [32]byte{}, err + } + src, err := source.Bytes() + if err != nil { + return [32]byte{}, err + } + dst, err := destination.Bytes() + if err != nil { + return [32]byte{}, err + } + contextDigest := context.Hash() + return Keccak256( + tagTransition, recordDigest[:], contextDigest[:], src, dst, + ), nil +} + +// NextPrefix extends a prefix with one receipt (XIREncoding.nextPrefix). +func NextPrefix(priorPrefix [32]byte, receipt Receipt) ([32]byte, error) { + receiptDigest, err := receipt.Hash() + if err != nil { + return [32]byte{}, err + } + return Keccak256(tagPrefix, priorPrefix[:], receiptDigest[:]), nil +} + +// BundleStart starts the length-bound bundle commitment +// (XIREncoding.bundleStart, which uses abi.encode rather than packed bytes). +func BundleStart(receiptCount int) [32]byte { + if receiptCount < 0 { + panic("xir: negative receipt count") + } + encoded := make([]byte, 0, 128) + encoded = append(encoded, word(64)...) + encoded = append(encoded, word(uint64(receiptCount))...) + encoded = append(encoded, word(uint64(len(tagBundle)))...) + encoded = append(encoded, rightPad(tagBundle)...) + return Keccak256(encoded) +} + +// BundleStep extends a bundle commitment with one index-bound receipt tuple +// (XIREncoding.bundleStep). +func BundleStep(prefix [32]byte, index int, receipt Receipt) [32]byte { + if index < 0 { + panic("xir: negative bundle index") + } + encoded := make([]byte, 0, 256) + encoded = append(encoded, word(192)...) + encoded = append(encoded, prefix[:]...) + encoded = append(encoded, word(uint64(index))...) + encoded = append(encoded, receipt.ProfileHash[:]...) + encoded = append(encoded, receipt.EvidenceHash[:]...) + encoded = append(encoded, receipt.TransitionHash[:]...) + encoded = append(encoded, word(uint64(len(tagBundleStep)))...) + encoded = append(encoded, rightPad(tagBundleStep)...) + return Keccak256(encoded) +} + +// BundleCommitment folds every receipt into the bundle commitment that the +// final adapter must have recorded. +func BundleCommitment(receipts []Receipt) [32]byte { + prefix := BundleStart(len(receipts)) + for index, receipt := range receipts { + prefix = BundleStep(prefix, index, receipt) + } + return prefix +} + +// ProfileHash returns the registered profile hash of one hop +// (src/xir_lab/native/multihop_deployer.py multihop_profile_hash). +func ProfileHash(route string, hopIndex int) ([32]byte, error) { + if hopIndex < 1 || hopIndex > len(route) { + return [32]byte{}, fmt.Errorf("multihop profile coordinates are invalid: route=%q hop=%d", route, hopIndex) + } + protocol := route[hopIndex-1 : hopIndex] + return Keccak256([]byte(fmt.Sprintf( + "%s:%s:%d:%s", tagMultihopProf, route, hopIndex, protocol, + ))), nil +} + +// GatewayTypedID returns the XIR gateway identifier of one local chain +// (multihop_deployer.multihop_gateway_typed_id). +func GatewayTypedID(chainID uint64) (TypedID, error) { + digest := Keccak256([]byte(fmt.Sprintf("%s:%d", tagGatewayID, chainID))) + return NewEVMID(digest[12:]) +} + +// AdapterKey returns the deployment key of one route adapter +// (multihop_deployer.adapter_key). +func AdapterKey(route string, hopIndex int, direction string) (string, error) { + if direction != "out" && direction != "in" { + return "", fmt.Errorf("multihop adapter direction is invalid: %q", direction) + } + if hopIndex < 1 || hopIndex > len(route) { + return "", fmt.Errorf("multihop adapter coordinates are invalid: route=%q hop=%d", route, hopIndex) + } + return fmt.Sprintf("route_%s_hop_%d_%s", lowerASCII(route), hopIndex, direction), nil +} + +func lowerASCII(value string) string { + out := []byte(value) + for index, char := range out { + if char >= 'A' && char <= 'Z' { + out[index] = char + ('a' - 'A') + } + } + return string(out) +} + +// FormatDigest renders a digest in 0x-prefixed hexadecimal. +func FormatDigest(digest [32]byte) string { + return "0x" + hex.EncodeToString(digest[:]) +} + +func uint64Bytes(value uint64) []byte { + out := make([]byte, 8) + for index := 7; index >= 0; index-- { + out[index] = byte(value) + value >>= 8 + } + return out +} + +func uint32Bytes(value uint32) []byte { + out := make([]byte, 4) + for index := 3; index >= 0; index-- { + out[index] = byte(value) + value >>= 8 + } + return out +} + +// word renders one 32-byte big-endian ABI word, as abi.encode does. +func word(value uint64) []byte { + out := make([]byte, 32) + for index := 31; index >= 24; index-- { + out[index] = byte(value) + value >>= 8 + } + return out +} + +func rightPad(value []byte) []byte { + if len(value)%32 == 0 { + return append([]byte(nil), value...) + } + out := make([]byte, len(value)) + copy(out, value) + return append(out, make([]byte, 32-len(value)%32)...) +} diff --git a/go-runtime/testdata/vectors.json b/go-runtime/testdata/vectors.json new file mode 100644 index 0000000..ed2eff4 --- /dev/null +++ b/go-runtime/testdata/vectors.json @@ -0,0 +1,860 @@ +{ + "adapter_keys": [ + { + "direction": "in", + "hop_index": 1, + "key": "route_h_hop_1_in", + "route": "H" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_h_hop_1_out", + "route": "H" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_l_hop_1_in", + "route": "L" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_l_hop_1_out", + "route": "L" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hh_hop_1_in", + "route": "HH" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hh_hop_1_out", + "route": "HH" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hh_hop_2_in", + "route": "HH" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hh_hop_2_out", + "route": "HH" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hhh_hop_1_in", + "route": "HHH" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hhh_hop_1_out", + "route": "HHH" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hhh_hop_2_in", + "route": "HHH" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hhh_hop_2_out", + "route": "HHH" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hhh_hop_3_in", + "route": "HHH" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hhh_hop_3_out", + "route": "HHH" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hhhh_hop_1_in", + "route": "HHHH" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hhhh_hop_1_out", + "route": "HHHH" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hhhh_hop_2_in", + "route": "HHHH" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hhhh_hop_2_out", + "route": "HHHH" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hhhh_hop_3_in", + "route": "HHHH" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hhhh_hop_3_out", + "route": "HHHH" + }, + { + "direction": "in", + "hop_index": 4, + "key": "route_hhhh_hop_4_in", + "route": "HHHH" + }, + { + "direction": "out", + "hop_index": 4, + "key": "route_hhhh_hop_4_out", + "route": "HHHH" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hl_hop_1_in", + "route": "HL" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hl_hop_1_out", + "route": "HL" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hl_hop_2_in", + "route": "HL" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hl_hop_2_out", + "route": "HL" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hlh_hop_1_in", + "route": "HLH" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hlh_hop_1_out", + "route": "HLH" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hlh_hop_2_in", + "route": "HLH" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hlh_hop_2_out", + "route": "HLH" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hlh_hop_3_in", + "route": "HLH" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hlh_hop_3_out", + "route": "HLH" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hlhl_hop_1_in", + "route": "HLHL" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hlhl_hop_1_out", + "route": "HLHL" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hlhl_hop_2_in", + "route": "HLHL" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hlhl_hop_2_out", + "route": "HLHL" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hlhl_hop_3_in", + "route": "HLHL" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hlhl_hop_3_out", + "route": "HLHL" + }, + { + "direction": "in", + "hop_index": 4, + "key": "route_hlhl_hop_4_in", + "route": "HLHL" + }, + { + "direction": "out", + "hop_index": 4, + "key": "route_hlhl_hop_4_out", + "route": "HLHL" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_lhlh_hop_1_in", + "route": "LHLH" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_lhlh_hop_1_out", + "route": "LHLH" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_lhlh_hop_2_in", + "route": "LHLH" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_lhlh_hop_2_out", + "route": "LHLH" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_lhlh_hop_3_in", + "route": "LHLH" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_lhlh_hop_3_out", + "route": "LHLH" + }, + { + "direction": "in", + "hop_index": 4, + "key": "route_lhlh_hop_4_in", + "route": "LHLH" + }, + { + "direction": "out", + "hop_index": 4, + "key": "route_lhlh_hop_4_out", + "route": "LHLH" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hhl_hop_1_in", + "route": "HHL" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hhl_hop_1_out", + "route": "HHL" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hhl_hop_2_in", + "route": "HHL" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hhl_hop_2_out", + "route": "HHL" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hhl_hop_3_in", + "route": "HHL" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hhl_hop_3_out", + "route": "HHL" + }, + { + "direction": "in", + "hop_index": 1, + "key": "route_hhhl_hop_1_in", + "route": "HHHL" + }, + { + "direction": "out", + "hop_index": 1, + "key": "route_hhhl_hop_1_out", + "route": "HHHL" + }, + { + "direction": "in", + "hop_index": 2, + "key": "route_hhhl_hop_2_in", + "route": "HHHL" + }, + { + "direction": "out", + "hop_index": 2, + "key": "route_hhhl_hop_2_out", + "route": "HHHL" + }, + { + "direction": "in", + "hop_index": 3, + "key": "route_hhhl_hop_3_in", + "route": "HHHL" + }, + { + "direction": "out", + "hop_index": 3, + "key": "route_hhhl_hop_3_out", + "route": "HHHL" + }, + { + "direction": "in", + "hop_index": 4, + "key": "route_hhhl_hop_4_in", + "route": "HHHL" + }, + { + "direction": "out", + "hop_index": 4, + "key": "route_hhhl_hop_4_out", + "route": "HHHL" + } + ], + "bundle_steps": [ + { + "bundle_start": "0x976245c7acd4f920d2923a15a5f9800686fafd321b0e0fa21eb5994c9f99bc58", + "evidence_hash": "0xa844794fb004eccd4dd8d02795cb21cae88cdfb000f331bff801503dc4eeaf7d", + "index": 0, + "profile_hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "receipt_count": 0, + "step": "0xf91e5e253c77aa0715c470f981d4e58f6daf50632844ddcae45e1ed3d4e5d74f", + "transition_hash": "0x692ef550df888b6e1292b31337f55e8d8b6d173f3d3e2eb8859497f9358a585d" + }, + { + "bundle_start": "0xdfcc476942e20bc9573011f2dc1133be17ebbe632447364ff37ebac8158ead00", + "evidence_hash": "0xa844794fb004eccd4dd8d02795cb21cae88cdfb000f331bff801503dc4eeaf7d", + "index": 1, + "profile_hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "receipt_count": 1, + "step": "0xad8c873aaaf817245f10520ecc8be3f7081a565f6843333ac2494b9cf9a9c6c2", + "transition_hash": "0x692ef550df888b6e1292b31337f55e8d8b6d173f3d3e2eb8859497f9358a585d" + }, + { + "bundle_start": "0xde1987ddb5f876b06db2b35c7dd9de18eb63ec58ed3bf71f6bbdabbab7056601", + "evidence_hash": "0xa844794fb004eccd4dd8d02795cb21cae88cdfb000f331bff801503dc4eeaf7d", + "index": 2, + "profile_hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "receipt_count": 2, + "step": "0x247978e9b639c3bc522597e3feb1fbd6112c51a845c35e41f31f26309d922819", + "transition_hash": "0x692ef550df888b6e1292b31337f55e8d8b6d173f3d3e2eb8859497f9358a585d" + } + ], + "constants": { + "chain_ids": [ + 3133701, + 3133702, + 3133703, + 3133704, + 3133705 + ], + "fixed_seed": "xir-native-multihop-switching-pilot-v1-100-per-route", + "fixture_keys": { + "dvn_signer": "0x78ce8604de52d5e052d2be0949c07e5b1670d3a9e50dcc9123d1e8529297caee", + "root_signer": "0xc30760628ebac0a2f1cfd7a1dd2e157d32deedc59927882f3791fe1fe29f07b0", + "validator": "0xd80b6a005047c8b5cc03fd078ce989169a3a89bb2161e7d76f027463856960cc" + }, + "payload_schedule": { + "minimum_bytes": 32, + "size_bucket_count": 4, + "size_step_bytes": 32 + }, + "registry_version": 1, + "routes": [ + "H", + "L", + "HH", + "HHH", + "HHHH", + "HL", + "HLH", + "HLHL", + "LHLH", + "HHL", + "HHHL" + ] + }, + "envelopes": [ + { + "bundle_commitment": "0x968a1831975f20e19e4840885c7d2016dc4dba5904ba55ef76af1e458900db95", + "context": { + "policy_hash": "0xca3591ef8c855ad3a061c2f4aad695d2b355c19b66941014f50c713b908cc83f", + "required_security": 1 + }, + "context_hash": "0x6e2fbf4263806e5d3fab2aabc395ee47a45e72415c1aab32a1d50e708f081749", + "envelope_encoding": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001ca3591ef8c855ad3a061c2f4aad695d2b355c19b66941014f50c713b908cc83f00000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000077c3b7d140a910f8a4e820dbb20ca354b9a64e073dc58abf5a69c96a2eb4928bb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014d47c1d26abdcbe3a6e3303b310b4c0c6852f30d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000041893dc71948c9cb8475fd73602030dac3df725460fb479af5c82ec5b1bd037d9667fe20f9b6690cd1179c0de5eb43522e8830c8c63c7ea9640de4148edfcf9d481b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001407207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc3971d98c8fe27db6df110db4dcc773b01b8dc6436c678969e64a854dc1d85cd54438fe262a3a8f6f0bb698fe74882860f18e2a1cffe9b27efd65e7121d53acb203c5081a9c6ff27905d330f57fc4bdd72ecc2da1c88b3f7b677f489fdb5b933b7b000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014d47c1d26abdcbe3a6e3303b310b4c0c6852f30d6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f498802c51a3082a3db9a8a5ae7eeebf80acea3b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001404349c417d52b054ac6d77851e72b781e420e9041b94f931a3140caa5016f155048b68ee95a1c561c82e5e721d17d1bdc4bb80c23c415308367adfe6eeef26a312e23b52eb02c8f9595e135ae247795972796b686847596f42c68ecf22ef2c49b6165b19f389497c14319fe840593ebedd56546cf5b5433a4ec1e53d0f24ff7c2000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f498802c51a3082a3db9a8a5ae7eeebf80acea3b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001404e75e254dba317ecb70532aab3e36267248c463000000000000000000000000", + "mid": "0x8e2a24ed795b66b6a552dcff58426551fbb9107db0d9cb908517d63ee27f9149", + "receipts": [ + { + "bundle_commitment_prefix": "0x818d1885800bef79a4f50fb5617238ab5065f742b62251aa5ddadf230c644a91", + "destination_gateway": "0xf498802c51a3082a3db9a8a5ae7eeebf80acea3b", + "evidence_hash": "0x71d98c8fe27db6df110db4dcc773b01b8dc6436c678969e64a854dc1d85cd544", + "next_prefix": "0x6165b19f389497c14319fe840593ebedd56546cf5b5433a4ec1e53d0f24ff7c2", + "prior_prefix": "0xc5081a9c6ff27905d330f57fc4bdd72ecc2da1c88b3f7b677f489fdb5b933b7b", + "profile_hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "receipt_hash": "0x1930fb09443dbbab3ea693cd4b766918e634e960b5bf55a9fa7566a724b64385", + "source_gateway": "0xd47c1d26abdcbe3a6e3303b310b4c0c6852f30d6", + "transition_hash": "0x38fe262a3a8f6f0bb698fe74882860f18e2a1cffe9b27efd65e7121d53acb203" + }, + { + "bundle_commitment_prefix": "0xc47f3dd7bee0c73cf53fa708ffdb5f57bfe9aaef4ae9e9bf25cd57af4eb042da", + "destination_gateway": "0x04e75e254dba317ecb70532aab3e36267248c463", + "evidence_hash": "0x48b68ee95a1c561c82e5e721d17d1bdc4bb80c23c415308367adfe6eeef26a31", + "next_prefix": "0xaa599c49b317cee94e4163fb0682994a4bb2de2cc31b6607f23914a8a07ee4fa", + "prior_prefix": "0x6165b19f389497c14319fe840593ebedd56546cf5b5433a4ec1e53d0f24ff7c2", + "profile_hash": "0x4349c417d52b054ac6d77851e72b781e420e9041b94f931a3140caa5016f1550", + "receipt_hash": "0x83a1618e3e8e6d0a69bbfdd23f8e0fd562901f521278340fcec12500c85b7e61", + "source_gateway": "0xf498802c51a3082a3db9a8a5ae7eeebf80acea3b", + "transition_hash": "0x2e23b52eb02c8f9595e135ae247795972796b686847596f42c68ecf22ef2c49b" + } + ], + "record": { + "destination_app": "0x0000000000000000000000000000000000000008", + "nonce": 7, + "payload_hash": "0x7c3b7d140a910f8a4e820dbb20ca354b9a64e073dc58abf5a69c96a2eb4928bb", + "source_app": "0x0000000000000000000000000000000000000007", + "source_gateway": "0xd47c1d26abdcbe3a6e3303b310b4c0c6852f30d6" + }, + "record_hash": "0x6d2ea042b0249c2dc0af4668ac390a5195c40f4c662b72a63c13829c4099af55", + "rid": "0x51baca2e9e5a1de18ed6a95fcb8b7ff64f8503fc5d9bceb21aa02ea510c1b32f", + "root_prefix": "0xc5081a9c6ff27905d330f57fc4bdd72ecc2da1c88b3f7b677f489fdb5b933b7b", + "root_signature": "0x893dc71948c9cb8475fd73602030dac3df725460fb479af5c82ec5b1bd037d9667fe20f9b6690cd1179c0de5eb43522e8830c8c63c7ea9640de4148edfcf9d481b", + "seed": 7, + "transition_hashes": [ + "0x38fe262a3a8f6f0bb698fe74882860f18e2a1cffe9b27efd65e7121d53acb203", + "0x2e23b52eb02c8f9595e135ae247795972796b686847596f42c68ecf22ef2c49b" + ] + }, + { + "bundle_commitment": "0x30b20deb5839139919b230b13b209599931b3601cf59f16c1286c4ecdc624915", + "context": { + "policy_hash": "0x7a0ab061c0a2f5b3c8555b694022ac640820062394422bb8f29914dadeab5f56", + "required_security": 1 + }, + "context_hash": "0xed1220539e0f74a6724c474961a0efbb3455f3851fe12c579c0a5d3a4145e3e5", + "envelope_encoding": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000017a0ab061c0a2f5b3c8555b694022ac640820062394422bb8f29914dadeab5f5600000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000001326d34abe52a9991e6d1f5314a6e901fe4def5185f542eea265850616f6c16e44000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014d47c1d26abdcbe3a6e3303b310b4c0c6852f30d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000130000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000041647155da218a1f6d89f3722c70c9d2876048b05803e6ba1af71704dd6ee6e1174e2e45dd90cb762f22474d598e2e2ab1e52e1685ab2d87f720e62406cc469d8b1b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001407207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39795ff267b38f9f40a36ec070e50d6305305ad7eda08a009d848bd42ca6aba01bb8842e8ef9466fd9da5fc653ed8e676f938553d6d9277d42b4e31875b6a66ac79b2c2fd85663308da868e630abd00746077e153ff6f478256dd0f7e10403489d000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014d47c1d26abdcbe3a6e3303b310b4c0c6852f30d6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f498802c51a3082a3db9a8a5ae7eeebf80acea3b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001404349c417d52b054ac6d77851e72b781e420e9041b94f931a3140caa5016f15501a6a03358b8673817bed83afbafe230c23a40832257fe3aabb48a902ebad1a3033b66af9ee1df22bd5496973898ad2e888d0f8baac4d1a208feca20ffe575c1d31f3cc4ef1910dbb57acae97956abe023e71bbef0c6bf52e5c228bea86d17f77000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f498802c51a3082a3db9a8a5ae7eeebf80acea3b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001404e75e254dba317ecb70532aab3e36267248c463000000000000000000000000", + "mid": "0x14824b071ce8937127a1c0f85ccd2cf1e6a0a98090b7fc07b3c41d15abcda32a", + "receipts": [ + { + "bundle_commitment_prefix": "0x1dcfae7a760099520f771aedf4eb1bc6e2a1ee13c5f2007f57e9c777a3dd724b", + "destination_gateway": "0xf498802c51a3082a3db9a8a5ae7eeebf80acea3b", + "evidence_hash": "0x795ff267b38f9f40a36ec070e50d6305305ad7eda08a009d848bd42ca6aba01b", + "next_prefix": "0x31f3cc4ef1910dbb57acae97956abe023e71bbef0c6bf52e5c228bea86d17f77", + "prior_prefix": "0x9b2c2fd85663308da868e630abd00746077e153ff6f478256dd0f7e10403489d", + "profile_hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "receipt_hash": "0xd9b6c14153e11c24a6f5481e81e9184b1178000c677a177f1ced21d3dedae027", + "source_gateway": "0xd47c1d26abdcbe3a6e3303b310b4c0c6852f30d6", + "transition_hash": "0xb8842e8ef9466fd9da5fc653ed8e676f938553d6d9277d42b4e31875b6a66ac7" + }, + { + "bundle_commitment_prefix": "0x5de1045741353507b5a25694bfaa2641ccf5dd921a6a82ce174d4d64f0a01383", + "destination_gateway": "0x04e75e254dba317ecb70532aab3e36267248c463", + "evidence_hash": "0x1a6a03358b8673817bed83afbafe230c23a40832257fe3aabb48a902ebad1a30", + "next_prefix": "0xfd8e077691a923b69071eba71b7b04808434135352656fc0e41e44010023506f", + "prior_prefix": "0x31f3cc4ef1910dbb57acae97956abe023e71bbef0c6bf52e5c228bea86d17f77", + "profile_hash": "0x4349c417d52b054ac6d77851e72b781e420e9041b94f931a3140caa5016f1550", + "receipt_hash": "0xf9e9ca9158438138b0238991e515f4593ebbf3faf2a391ad73d62573b754895d", + "source_gateway": "0xf498802c51a3082a3db9a8a5ae7eeebf80acea3b", + "transition_hash": "0x33b66af9ee1df22bd5496973898ad2e888d0f8baac4d1a208feca20ffe575c1d" + } + ], + "record": { + "destination_app": "0x0000000000000000000000000000000000000014", + "nonce": 19, + "payload_hash": "0x26d34abe52a9991e6d1f5314a6e901fe4def5185f542eea265850616f6c16e44", + "source_app": "0x0000000000000000000000000000000000000013", + "source_gateway": "0xd47c1d26abdcbe3a6e3303b310b4c0c6852f30d6" + }, + "record_hash": "0xe7fcc3e6a8c1a7574550a73e837f26ebef3f9f4333e2f9a0f90c8a74f60dcc34", + "rid": "0xc7e104b23b6b9367131dc21c66d4914766fdcc75f82691397ac1ec77a6985156", + "root_prefix": "0x9b2c2fd85663308da868e630abd00746077e153ff6f478256dd0f7e10403489d", + "root_signature": "0x647155da218a1f6d89f3722c70c9d2876048b05803e6ba1af71704dd6ee6e1174e2e45dd90cb762f22474d598e2e2ab1e52e1685ab2d87f720e62406cc469d8b1b", + "seed": 19, + "transition_hashes": [ + "0xb8842e8ef9466fd9da5fc653ed8e676f938553d6d9277d42b4e31875b6a66ac7", + "0x33b66af9ee1df22bd5496973898ad2e888d0f8baac4d1a208feca20ffe575c1d" + ] + } + ], + "gateway_typed_ids": [ + { + "chain_id": 3133701, + "kind": 1, + "value": "0xd47c1d26abdcbe3a6e3303b310b4c0c6852f30d6" + }, + { + "chain_id": 3133702, + "kind": 1, + "value": "0xf498802c51a3082a3db9a8a5ae7eeebf80acea3b" + }, + { + "chain_id": 3133703, + "kind": 1, + "value": "0x04e75e254dba317ecb70532aab3e36267248c463" + }, + { + "chain_id": 3133704, + "kind": 1, + "value": "0xe624f6801873be700ee458cfc79244904cdc74a6" + }, + { + "chain_id": 3133705, + "kind": 1, + "value": "0x1beb81b7d85ce0595f1eddeb4f91e1ba6aca3491" + } + ], + "generator": { + "fixed_seed": "xir-native-multihop-switching-pilot-v1-100-per-route", + "python_source": "src/xir_lab/native", + "script": "scripts/generate_go_parity_vectors.py" + }, + "hyperlane_evidence": [ + { + "body": "0x000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000002068e7dcf02fd92c267a26db6531e4bc62c35b7ed03e6bda41b05de8c62d1b3e65727ecfa16199290e80d554ea14b813452d182f6de68ae81d48b889d96829c98f00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "current_profile": "0x68e7dcf02fd92c267a26db6531e4bc62c35b7ed03e6bda41b05de8c62d1b3e65", + "current_transition": "0x727ecfa16199290e80d554ea14b813452d182f6de68ae81d48b889d96829c98f", + "domain": 31337, + "evidence_hash": "0xd39ffaa74dd9e28ace694df998f9bc641beda92e785f44daadfc194d7288e5a1", + "hop_index": 1, + "prior_evidence": [], + "prior_profiles": [], + "prior_transitions": [], + "sender": "0x000000000000000000000000dddddddddddddddddddddddddddddddddddddddd" + }, + { + "body": "0x0000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000020420146ea3a9e95ac4cc729e09c685a5d4f6c6685ee223697c50db4ca91ad4aead688b87cedf0482b9889db417d85452d8e55605ad8127cf81f221ef0ddbcb9b400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001f09ceb32db9f5767d4dfefb00e91ca83a8adeab0392e6d59c16a5600f68c31440000000000000000000000000000000000000000000000000000000000000001d0ecc94c51ee774e39d99cbe6551793052fbc80178d08bd2bb553100b4d8943600000000000000000000000000000000000000000000000000000000000000014757ff88de7b2b3109a8e5e5dbd43ab93c7eb98d5d9bbadd37f99e70cfc970aa", + "current_profile": "0x420146ea3a9e95ac4cc729e09c685a5d4f6c6685ee223697c50db4ca91ad4aea", + "current_transition": "0xd688b87cedf0482b9889db417d85452d8e55605ad8127cf81f221ef0ddbcb9b4", + "domain": 31338, + "evidence_hash": "0xa5740fa8213c0e845622994d4fae575f2ee7e48fd3d89b4b12b5ac69f4cede84", + "hop_index": 2, + "prior_evidence": [ + "0xd0ecc94c51ee774e39d99cbe6551793052fbc80178d08bd2bb553100b4d89436" + ], + "prior_profiles": [ + "0xf09ceb32db9f5767d4dfefb00e91ca83a8adeab0392e6d59c16a5600f68c3144" + ], + "prior_transitions": [ + "0x4757ff88de7b2b3109a8e5e5dbd43ab93c7eb98d5d9bbadd37f99e70cfc970aa" + ], + "sender": "0x000000000000000000000000dddddddddddddddddddddddddddddddddddddddd" + } + ], + "layerzero_packets": [ + { + "commit_verification_calldata": "0x0894edf10000000000000000000000000000000000000000000000000000000000000040fcdc6c7f6bd3fe1870553d135f6c5330cfd5d5d0f70deccc2a92f65a4dcc590d000000000000000000000000000000000000000000000000000000000000005101000000000000000100009ce1000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009d27000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb000000000000000000000000000000", + "destination_eid": 40231, + "dvn_execute_calldata": "0xb143044b0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000009d27000000000000000000000000cccccccccccccccccccccccccccccccccccccccc00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000713fb30000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e40223536e0000000000000000000000000000000000000000000000000000000000000060fcdc6c7f6bd3fe1870553d135f6c5330cfd5d5d0f70deccc2a92f65a4dcc590d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000005101000000000000000100009ce1000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009d27000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000417a2dd97259c3d2548fadf986b504b76a2f11c0f6b78cfa28cb772fcdd8112f727fa5d76145a7e55073b44eece1a4117e3ac439990c0129852cae5522fe32080a1b00000000000000000000000000000000000000000000000000000000000000", + "dvn_instruction": { + "call_data": "0x0223536e0000000000000000000000000000000000000000000000000000000000000060fcdc6c7f6bd3fe1870553d135f6c5330cfd5d5d0f70deccc2a92f65a4dcc590d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000005101000000000000000100009ce1000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009d27000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb000000000000000000000000000000", + "expiration": 1900000000, + "instruction_hash": "0x7adeab3e4d02fd8a1af16361b67fbb5dae1768d251a1039b48a9837879787dfa", + "signature": "0x7a2dd97259c3d2548fadf986b504b76a2f11c0f6b78cfa28cb772fcdd8112f727fa5d76145a7e55073b44eece1a4117e3ac439990c0129852cae5522fe32080a1b", + "target": "0xcccccccccccccccccccccccccccccccccccccccc", + "vid": 40231 + }, + "encoded_packet": "0x01000000000000000100009ce1000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009d27000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbf6fc1e02a80155b3ac9b6502b4d33dc8137b8ad6a8ffae86a4591b4eefe05fa000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e", + "executor_calldata": "0xcfc325700000000000000000000000000000000000000000000000000000000000000020000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000000000000000000000000000000000000000000000000000000000009ce1000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000000000000000000000000000000000000000000000000000000001f6fc1e02a80155b3ac9b6502b4d33dc8137b8ad6a8ffae86a4591b4eefe05fa0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000016e360000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e0000000000000000000000000000000000000000000000000000000000000000", + "executor_options": "0x0003010011010000000000000000000000000016e360", + "guid": "0xf6fc1e02a80155b3ac9b6502b4d33dc8137b8ad6a8ffae86a4591b4eefe05fa0", + "message": "0x00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e", + "nonce": 1, + "payload_hash": "0xfcdc6c7f6bd3fe1870553d135f6c5330cfd5d5d0f70deccc2a92f65a4dcc590d", + "receiver": "0x000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sender": "0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_eid": 40161 + }, + { + "commit_verification_calldata": "0x0894edf10000000000000000000000000000000000000000000000000000000000000040bc1478b1edf1d277dd823e2dcc2abfcfd7215262dcd5a4e2666e88e4bafa9e64000000000000000000000000000000000000000000000000000000000000005101000000000000000900009d27000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009ce1000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb000000000000000000000000000000", + "destination_eid": 40161, + "dvn_execute_calldata": "0xb143044b0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000009ce1000000000000000000000000cccccccccccccccccccccccccccccccccccccccc00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000713fb30000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e40223536e0000000000000000000000000000000000000000000000000000000000000060bc1478b1edf1d277dd823e2dcc2abfcfd7215262dcd5a4e2666e88e4bafa9e640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000005101000000000000000900009d27000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009ce1000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000416bd919354e7abf2fd9829abbddc65f714f89ed5c9bfebf4ffca5696d2b217879680b008c9901ae639b7669daf97792eddac1ec0df892c926c40d4c72c15b18641b00000000000000000000000000000000000000000000000000000000000000", + "dvn_instruction": { + "call_data": "0x0223536e0000000000000000000000000000000000000000000000000000000000000060bc1478b1edf1d277dd823e2dcc2abfcfd7215262dcd5a4e2666e88e4bafa9e640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000005101000000000000000900009d27000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009ce1000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb000000000000000000000000000000", + "expiration": 1900000000, + "instruction_hash": "0xa5ff7524163081798c7d0f2c73f1186a9085d536ceda839d14c7d3821eb1f666", + "signature": "0x6bd919354e7abf2fd9829abbddc65f714f89ed5c9bfebf4ffca5696d2b217879680b008c9901ae639b7669daf97792eddac1ec0df892c926c40d4c72c15b18641b", + "target": "0xcccccccccccccccccccccccccccccccccccccccc", + "vid": 40161 + }, + "encoded_packet": "0x01000000000000000900009d27000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00009ce1000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5338286368c3033d9819a22d39477460f3d86216b9cc2c325429e06419077ea000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e", + "executor_calldata": "0xcfc325700000000000000000000000000000000000000000000000000000000000000020000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000000000000000000000000000000000000000000000000000000000009d27000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00000000000000000000000000000000000000000000000000000000000000095338286368c3033d9819a22d39477460f3d86216b9cc2c325429e06419077ea0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000016e360000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e0000000000000000000000000000000000000000000000000000000000000000", + "executor_options": "0x0003010011010000000000000000000000000016e360", + "guid": "0x5338286368c3033d9819a22d39477460f3d86216b9cc2c325429e06419077ea0", + "message": "0x00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002018aecb8943cdd9a2ecb727bf74d1a0038529561a9ac7eae722de25f651b1d96e", + "nonce": 9, + "payload_hash": "0xbc1478b1edf1d277dd823e2dcc2abfcfd7215262dcd5a4e2666e88e4bafa9e64", + "receiver": "0x000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sender": "0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_eid": 40231 + } + ], + "payloads": [ + { + "application_bytes": "0xb2eacea796c328af7b3d366e8decab74c98d7924804eb746758e0913dd9229c5", + "application_sha256": "0bfa97114f3692521a2c1e8b275fe333c309e91c4fd7bb366f5fdf92cf814906", + "attempt_id": "xir-multihop-fixture-000001", + "attempt_key": "0xd2f72c30025fe8d0f15434181b40c88e7d691c237957cb24f907141a23f59aad", + "payload_encoding": "0x0000000000000000000000000000000000000000000000000000000000000020d2f72c30025fe8d0f15434181b40c88e7d691c237957cb24f907141a23f59aad0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000002484c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020b2eacea796c328af7b3d366e8decab74c98d7924804eb746758e0913dd9229c5", + "phase": "smoke", + "route": "HL", + "route_sequence": 0 + }, + { + "application_bytes": "0x6b7ffa2d457ee7aa8e06f26eac82c4895a3f6a9390916a4f6c1851a352b2b6ca7afac6efada0493d169541754db3bb5de43b82e545168727cf146fb31290d1a6067c6dee482531662c3a8f7d46d10666fe0d801789f2ca5a18fef872c107ac670f5727d4381cf4619e90c1a0cddb3592fcfcae0d5423d4c6f1e86131318b314f", + "application_sha256": "4db75a6479f86d53a0c4ed1f2495b0b9deb1d12aada412a451cfd170fb65830c", + "attempt_id": "xir-multihop-fixture-000002", + "attempt_key": "0xd95cd3af2129b8b135a9cb434e49848e1e1c136261af483a9c38bbcfcd9317b4", + "payload_encoding": "0x0000000000000000000000000000000000000000000000000000000000000020d95cd3af2129b8b135a9cb434e49848e1e1c136261af483a9c38bbcfcd9317b40000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000024c4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000806b7ffa2d457ee7aa8e06f26eac82c4895a3f6a9390916a4f6c1851a352b2b6ca7afac6efada0493d169541754db3bb5de43b82e545168727cf146fb31290d1a6067c6dee482531662c3a8f7d46d10666fe0d801789f2ca5a18fef872c107ac670f5727d4381cf4619e90c1a0cddb3592fcfcae0d5423d4c6f1e86131318b314f", + "phase": "smoke", + "route": "LH", + "route_sequence": 3 + }, + { + "application_bytes": "0x5bea514dcb0a9040e9c19b0fef5270369187421a0b7d4f99202e1b72f90f5eb0167243a578b589cea736f9f3126be272192671ee6210d615075c3a2e66f1c5a5", + "application_sha256": "94b95edae8c6f060427ca42cf123cda660619f8ea2d64030651897e678a3c94e", + "attempt_id": "xir-multihop-fixture-000003", + "attempt_key": "0x4f09c97f08831b7604c419099362a1cd405455c1003641f9ecfa6d6253f0006f", + "payload_encoding": "0x00000000000000000000000000000000000000000000000000000000000000204f09c97f08831b7604c419099362a1cd405455c1003641f9ecfa6d6253f0006f0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000003484848000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000405bea514dcb0a9040e9c19b0fef5270369187421a0b7d4f99202e1b72f90f5eb0167243a578b589cea736f9f3126be272192671ee6210d615075c3a2e66f1c5a5", + "phase": "publication_smoke", + "route": "HHH", + "route_sequence": 5 + } + ], + "profile_hashes": [ + { + "hash": "0x7aaef6b60756a31ca0f1555ca46862b3a611cfbf49badc494be51c8401397f49", + "hop_index": 1, + "route": "H" + }, + { + "hash": "0xcbd0da437b61a51b59b8fe494e997c94eea4add0af4972909dd1b64dfecc3a90", + "hop_index": 1, + "route": "L" + }, + { + "hash": "0x35ae22e3c38ee7346b95dd4e70b4a835ccfb55580b0b3a18bfb70e70d84cdfbc", + "hop_index": 1, + "route": "HH" + }, + { + "hash": "0x42fbebf014363523ed7688afd28b4b733b52f575c2ab68af729bf335e24be5e9", + "hop_index": 2, + "route": "HH" + }, + { + "hash": "0xa2c108661fd59595d17b418c28142419810c0d41cca057074a3f6bdf3fca5d7b", + "hop_index": 1, + "route": "HHH" + }, + { + "hash": "0x2bfb9ebf87f7a274edc4d753a0aad20609a3bfc9e04e556e793ca12a1625d3ae", + "hop_index": 2, + "route": "HHH" + }, + { + "hash": "0x4b3628d5fa6e8e2d226454c947c8c0673b0d435421dc6459fedc86dcad63133f", + "hop_index": 3, + "route": "HHH" + }, + { + "hash": "0xdfe0e421601ab7d5b9f6b1db751ca1a0fde1fbc2f25c3673a3f4f791d2d0a35d", + "hop_index": 1, + "route": "HHHH" + }, + { + "hash": "0x3638f31c68ebe9fa6ab334f5a19b5ae1d4c7d457ba4c991b74d8415f611e119a", + "hop_index": 2, + "route": "HHHH" + }, + { + "hash": "0x8ed40cc5a43ba02b841146d8b9e3e73a68a28099e96b5b91fdcea71c2699c8ed", + "hop_index": 3, + "route": "HHHH" + }, + { + "hash": "0x49bf81613253b86ea369301a445a5fbf5e396d2896f9fde73ec7d6b00b9ebfe8", + "hop_index": 4, + "route": "HHHH" + }, + { + "hash": "0x7207488a906962e4f87c0d9249433974b1410bc13eb392aa7e359aaf8c72dc39", + "hop_index": 1, + "route": "HL" + }, + { + "hash": "0x4349c417d52b054ac6d77851e72b781e420e9041b94f931a3140caa5016f1550", + "hop_index": 2, + "route": "HL" + }, + { + "hash": "0x67616279bf264dff3229edd9251099e156c840745b0482cbf98878d57a454d31", + "hop_index": 1, + "route": "HLH" + }, + { + "hash": "0x9271642687fe5690d46aefe8608cf5ddaa22bc79b9a43e3a86512ca1a141ac99", + "hop_index": 2, + "route": "HLH" + }, + { + "hash": "0xdbad5af0276df36ac9416348ae9c542396cf53bd1b58744df4bd0dcecc3ffe2c", + "hop_index": 3, + "route": "HLH" + }, + { + "hash": "0x0d6c74f1b939ad712ec502dbd9ec4f8043491871ce5414fc16659294753df09f", + "hop_index": 1, + "route": "HLHL" + }, + { + "hash": "0x3c8e611d44eaec3aac22708c850e7966c9be5c75eb7610e3d5add5471a149c1e", + "hop_index": 2, + "route": "HLHL" + }, + { + "hash": "0xe9488c0057beda25d932dbfdf1b2eaa535f35e2ef7702b98c5eb7988c161b9fd", + "hop_index": 3, + "route": "HLHL" + }, + { + "hash": "0x20b2016dcfcefa0cd3ab500eb1e8fd903170ceb30cda3cf4c877f62367cfcf42", + "hop_index": 4, + "route": "HLHL" + }, + { + "hash": "0xc3dd7f40068cc31496e2d89c6a11ca804807ff52387f487b6accbc84e12016e7", + "hop_index": 1, + "route": "LHLH" + }, + { + "hash": "0x4de062f292613552dd78cc294c5d569963f21fb52cb0f0d32f8ac18cc4fc5b8e", + "hop_index": 2, + "route": "LHLH" + }, + { + "hash": "0xcc9b412db98e07171fc90bcebfa2aed4f70fef594b48a230debdded7f01af9c1", + "hop_index": 3, + "route": "LHLH" + }, + { + "hash": "0x44de43e238628d04514b67e03782d0aba672bdf04d277b127663afefd4d6f4b6", + "hop_index": 4, + "route": "LHLH" + }, + { + "hash": "0x31882387a9a7c868354ed8af85e451020c4f5d5d86bc7d06a54e8e1a94a61854", + "hop_index": 1, + "route": "HHL" + }, + { + "hash": "0xa89680ae84bb51fcc2a37d42fd93176091507fa34bf3df7b675c59ae44a4ae0e", + "hop_index": 2, + "route": "HHL" + }, + { + "hash": "0x467d93e10edfc7cb01f8ea63641c580b17a91d7aa6589b50933b61661bb77a69", + "hop_index": 3, + "route": "HHL" + }, + { + "hash": "0xa4b57a4796e1fb546d6bdbd7cf3a239899b7f402a3ae7ac5febf7e82f3e8199a", + "hop_index": 1, + "route": "HHHL" + }, + { + "hash": "0x8d45292f8d5367fcda1a69e1a9c7390fa1d8d2328d68ccec798f357ef9780898", + "hop_index": 2, + "route": "HHHL" + }, + { + "hash": "0x2b04e7516e0b07e0bc686330626d3ead8818a183f83c1f1a994359ef795e1133", + "hop_index": 3, + "route": "HHHL" + }, + { + "hash": "0xadc5363e23c413785637637f5497aa1d0033c009a6b4d57d008e9a00f37e9d7d", + "hop_index": 4, + "route": "HHHL" + } + ], + "schema_version": "xir-go-parity-vectors-v1", + "typed_ids": [ + { + "hash": "0xc0def0ee850471689d841319e5784f7ecfd9f164be3313baedb88c1df6e9ed30", + "kind": 1, + "value": "0x0000000000000000000000000000000000000001" + }, + { + "hash": "0x370b5952fa32472c0d31533b3ecbf768b2aa49a2363bdc7adf4dc99a496afb00", + "kind": 1, + "value": "0x000000000000000000000000000000000000002a" + }, + { + "hash": "0x85655465c9e7eca70233c662aa9c91312052a11ec95b0afcd9fbce0238b4fafd", + "kind": 2, + "value": "0x0000000000000000000000000000000000000000000000000000000000000005" + } + ] +} diff --git a/schemas/xir-go-runtime-config-v1.schema.json b/schemas/xir-go-runtime-config-v1.schema.json new file mode 100644 index 0000000..75df8de --- /dev/null +++ b/schemas/xir-go-runtime-config-v1.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/lysrain21/XIR/schemas/xir-go-runtime-config-v1.schema.json", + "title": "XIR Go runtime configuration", + "description": "One invocation of the Go execution layer: chains, attempts, deployment document, durable state paths, and the names of the environment variables that hold the signing keys.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "chains", + "attempts", + "payload_schedule", + "fixed_seed", + "deployment", + "state_path", + "raw_root" + ], + "properties": { + "schema_version": {"const": "xir-go-runtime-config-v1"}, + "chains": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "chain_id", "rpc_url", "hyperlane_domain", "layerzero_eid"], + "properties": { + "role": {"enum": ["a", "b", "c", "d", "e"]}, + "chain_id": {"type": "integer", "minimum": 1}, + "rpc_url": {"type": "string", "pattern": "^https?://"}, + "hyperlane_domain": {"type": "integer", "minimum": 0}, + "layerzero_eid": {"type": "integer", "minimum": 0} + } + } + }, + "attempts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["attempt_id", "phase", "route", "route_sequence"], + "properties": { + "attempt_id": {"type": "string", "minLength": 1}, + "phase": {"type": "string", "minLength": 1}, + "route": {"type": "string", "pattern": "^[HL]{1,4}$"}, + "route_sequence": {"type": "integer", "minimum": 0}, + "switch_count": {"type": "integer", "minimum": 0}, + "payload_sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"} + } + } + }, + "payload_schedule": { + "type": "object", + "additionalProperties": false, + "required": ["minimum_bytes", "size_bucket_count", "size_step_bytes"], + "properties": { + "minimum_bytes": {"type": "integer", "minimum": 0}, + "size_bucket_count": {"type": "integer", "minimum": 1}, + "size_step_bytes": {"type": "integer", "minimum": 0} + } + }, + "fixed_seed": {"type": "string", "minLength": 1}, + "policy_label": {"type": "string"}, + "deployment": {"type": "string", "minLength": 1}, + "artifacts_root": {"type": "string"}, + "protocol_root": {"type": "string"}, + "state_path": {"type": "string", "minLength": 1}, + "raw_root": {"type": "string", "minLength": 1}, + "timeout_seconds": {"type": "integer", "minimum": 1}, + "poll_millis": {"type": "integer", "minimum": 1}, + "concurrency": {"type": "integer", "minimum": 1}, + "gas_limit": {"type": "integer", "minimum": 1}, + "stop_file": {"type": "string"}, + "embedded_agents": {"type": "boolean"}, + "fault_after_stage": {"type": "string"}, + "finality": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": {"enum": ["rpc-finalized", "confirmations", "none"]}, + "confirmations": {"type": "integer", "minimum": 0}, + "timeout_seconds": {"type": "integer", "minimum": 1} + } + }, + "key_environment": { + "type": "object", + "additionalProperties": false, + "properties": { + "runner": {"type": "string"}, + "root_signer": {"type": "string"}, + "layerzero_worker": {"type": "string"}, + "hyperlane_validator": {"type": "string"}, + "hyperlane_relayer": {"type": "string"} + } + } + } +} diff --git a/scripts/generate_go_parity_vectors.py b/scripts/generate_go_parity_vectors.py new file mode 100644 index 0000000..12fd52d --- /dev/null +++ b/scripts/generate_go_parity_vectors.py @@ -0,0 +1,478 @@ +"""Generate the Go runtime parity vectors from the Python reference runtime. + +The Go runtime in ``go-runtime/`` re-implements the XIR wire encoding, the +transaction-level signatures, and the protocol codecs. This script derives +vectors from the shipping Python modules and writes them to one JSON document +that ``go-runtime/internal/xir/vectors_test.go`` and the protocol package tests +compare against byte for byte. + +Usage: + uv run python scripts/generate_go_parity_vectors.py --output go-runtime/testdata/vectors.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +from eth_abi.abi import encode +from eth_account import Account +from eth_account.messages import encode_defunct +from eth_utils import keccak # type: ignore[attr-defined] + +from xir_lab.native.layerzero import ( + build_dvn_instruction, + decode_packet, + encode_commit_verification, + encode_dvn_execute, + encode_executor_submission, + executor_lz_receive_options, +) +from xir_lab.native.multihop_deployer import ( + REGISTRY_VERSION, + adapter_key, + multihop_gateway_typed_id, + multihop_profile_hash, +) +from xir_lab.native.multihop_scalability import ROUTE_ORDER +from xir_lab.native.xir_trace import ( + XIRContext, + XIRReceipt, + XIRRecord, + bundle_commitment, + bundle_start, + bundle_step, + context_hash, + message_id, + next_prefix, + receipt_hash, + receipt_tuple, + record_hash, + record_tuple, + root_id, + root_prefix, + transition_hash, +) + +SCHEMA_VERSION = "xir-go-parity-vectors-v1" +FIXED_SEED = "xir-native-multihop-switching-pilot-v1-100-per-route" +PAYLOAD_SCHEDULE = { + "minimum_bytes": 32, + "size_bucket_count": 4, + "size_step_bytes": 32, +} + + +def _fixture_key(label: str) -> str: + """Return one deterministic disposable signing key for local tests only.""" + + return "0x" + keccak(text=f"xir-go-parity-fixture-signer:{label}").hex() + + +FIXTURE_KEYS = { + "root_signer": _fixture_key("root-signer"), + "dvn_signer": _fixture_key("dvn-signer"), + "validator": _fixture_key("validator"), +} +CHAIN_IDS = (3133701, 3133702, 3133703, 3133704, 3133705) +ATTEMPTS = ( + { + "attempt_id": "xir-multihop-fixture-000001", + "phase": "smoke", + "route": "HL", + "route_sequence": 0, + }, + { + "attempt_id": "xir-multihop-fixture-000002", + "phase": "smoke", + "route": "LH", + "route_sequence": 3, + }, + { + "attempt_id": "xir-multihop-fixture-000003", + "phase": "publication_smoke", + "route": "HHH", + "route_sequence": 5, + }, +) + + +def _hex(value: bytes) -> str: + return "0x" + value.hex() + + +def _application_payload(attempt: dict[str, Any], schedule: dict[str, int]) -> tuple[bytes, bytes]: + size = ( + schedule["minimum_bytes"] + + (attempt["route_sequence"] % schedule["size_bucket_count"]) * schedule["size_step_bytes"] + ) + material = ( + f"xir-multihop-v1:{FIXED_SEED}:{attempt['phase']}:{attempt['route_sequence']}".encode() + ) + application = bytearray() + counter = 0 + while len(application) < size: + application.extend(hashlib.sha256(material + counter.to_bytes(4, "big")).digest()) + counter += 1 + application_bytes = bytes(application[:size]) + encoded = encode( + ["(bytes32,bytes,uint64,bytes)"], + [ + ( + keccak(text=attempt["attempt_id"]), + attempt["route"].encode("ascii"), + attempt["route_sequence"], + application_bytes, + ) + ], + ) + return application_bytes, encoded + + +def _record(seed: int) -> XIRRecord: + gateway = multihop_gateway_typed_id(CHAIN_IDS[0]) + source_app = (1, bytes.fromhex(f"{seed:040x}")) + destination_app = (1, bytes.fromhex(f"{seed + 1:040x}")) + return XIRRecord( + source_gateway=(gateway[0], gateway[1]), + source_app=source_app, + destination_app=destination_app, + nonce=seed, + payload_hash=keccak(text=f"xir-go-parity-payload-{seed}"), + ) + + +def _context(seed: int) -> XIRContext: + return XIRContext(1, keccak(text=f"xir-go-parity-policy-{seed}")) + + +def _receipt( + seed: int, + hop_index: int, + record: XIRRecord, + context: XIRContext, + route: str = "HL", +) -> XIRReceipt: + """Build a receipt whose transition hash is the on-chain derivation.""" + + source = multihop_gateway_typed_id(CHAIN_IDS[hop_index - 1]) + destination = multihop_gateway_typed_id(CHAIN_IDS[hop_index]) + return XIRReceipt( + source_gateway=(source[0], source[1]), + destination_gateway=(destination[0], destination[1]), + profile_hash=multihop_profile_hash(route, hop_index), + evidence_hash=keccak(text=f"xir-go-parity-evidence-{seed}-{hop_index}"), + transition_hash=transition_hash( + record, + context, + (source[0], source[1]), + (destination[0], destination[1]), + ), + prior_prefix=keccak(text=f"xir-go-parity-prefix-{seed}-{hop_index}"), + ) + + +def _envelope_vectors() -> list[dict[str, Any]]: + vectors: list[dict[str, Any]] = [] + for seed in (7, 19): + record = _record(seed) + context = _context(seed) + rid = root_id(record, context, REGISTRY_VERSION) + receipts = [_receipt(seed, hop_index, record, context) for hop_index in range(1, 3)] + trace_receipts = [] + prior = root_prefix(rid) + for receipt in receipts: + trace_receipts.append( + XIRReceipt( + source_gateway=receipt.source_gateway, + destination_gateway=receipt.destination_gateway, + profile_hash=receipt.profile_hash, + evidence_hash=receipt.evidence_hash, + transition_hash=receipt.transition_hash, + prior_prefix=prior, + ) + ) + prior = next_prefix(trace_receipts[-1]) + signature = bytes( + Account.sign_message( + encode_defunct(primitive=rid), private_key=FIXTURE_KEYS["root_signer"] + ).signature + ) + envelope = ( + record_tuple(record), + (context.required_security, context.policy_hash), + (REGISTRY_VERSION, signature), + [receipt_tuple(receipt) for receipt in trace_receipts], + ) + encoded = encode( + [ + ( + "(((uint8,bytes),(uint8,bytes),(uint8,bytes),uint64,bytes32)," + "(uint8,bytes32),(uint32,bytes)," + "((uint8,bytes),(uint8,bytes),bytes32,bytes32,bytes32,bytes32)[])" + ) + ], + [envelope], + ) + vectors.append( + { + "seed": seed, + "record": { + "source_gateway": _hex(record.source_gateway[1]), + "source_app": _hex(record.source_app[1]), + "destination_app": _hex(record.destination_app[1]), + "nonce": record.nonce, + "payload_hash": _hex(record.payload_hash), + }, + "context": { + "required_security": context.required_security, + "policy_hash": _hex(context.policy_hash), + }, + "rid": _hex(rid), + "mid": _hex(message_id(rid, record.destination_app)), + "root_prefix": _hex(root_prefix(rid)), + "transition_hashes": [ + _hex( + transition_hash( + record, + context, + receipt.source_gateway, + receipt.destination_gateway, + ) + ) + for receipt in trace_receipts + ], + "receipts": [ + { + "source_gateway": _hex(receipt.source_gateway[1]), + "destination_gateway": _hex(receipt.destination_gateway[1]), + "profile_hash": _hex(receipt.profile_hash), + "evidence_hash": _hex(receipt.evidence_hash), + "transition_hash": _hex(receipt.transition_hash), + "prior_prefix": _hex(receipt.prior_prefix), + "receipt_hash": _hex(receipt_hash(receipt)), + "next_prefix": _hex(next_prefix(receipt)), + "bundle_commitment_prefix": _hex(bundle_commitment([receipt])), + } + for receipt in trace_receipts + ], + "bundle_commitment": _hex(bundle_commitment(trace_receipts)), + "root_signature": _hex(signature), + "envelope_encoding": _hex(encoded), + "record_hash": _hex(record_hash(record)), + "context_hash": _hex(context_hash(context)), + } + ) + return vectors + + +def _packet_vectors() -> list[dict[str, Any]]: + vectors: list[dict[str, Any]] = [] + for nonce, source_eid, destination_eid in ((1, 40161, 40231), (9, 40231, 40161)): + message = encode( + ["uint8", "bytes"], [3, encode(["bytes32"], [keccak(text="xir-go-parity")])] + ) + sender = bytes.fromhex("00" * 12 + "aa" * 20) + receiver = bytes.fromhex("00" * 12 + "bb" * 20) + guid = keccak(text=f"xir-go-parity-guid-{nonce}") + header = ( + (1).to_bytes(1, "big") + + nonce.to_bytes(8, "big") + + source_eid.to_bytes(4, "big") + + sender + + destination_eid.to_bytes(4, "big") + + receiver + ) + encoded = header + guid + message + packet = decode_packet(encoded) + instruction = build_dvn_instruction( + vid=destination_eid, + receive_uln_address="0x" + "cc" * 20, + packet=packet, + confirmations=1, + expiration=1_900_000_000, + signer_private_key=FIXTURE_KEYS["dvn_signer"], + ) + vectors.append( + { + "nonce": nonce, + "encoded_packet": _hex(encoded), + "source_eid": source_eid, + "destination_eid": destination_eid, + "sender": _hex(sender), + "receiver": _hex(receiver), + "guid": _hex(guid), + "message": _hex(message), + "payload_hash": _hex(packet.payload_hash), + "dvn_instruction": { + "vid": instruction.vid, + "target": instruction.target, + "call_data": _hex(instruction.call_data), + "expiration": instruction.expiration, + "instruction_hash": _hex(instruction.instruction_hash), + "signature": _hex(instruction.signature), + }, + "dvn_execute_calldata": _hex(encode_dvn_execute(instruction)), + "commit_verification_calldata": _hex(encode_commit_verification(packet)), + "executor_calldata": _hex(encode_executor_submission(packet, 1_500_000)), + "executor_options": _hex(executor_lz_receive_options(1_500_000)), + } + ) + return vectors + + +def _hyperlane_evidence_vectors() -> list[dict[str, Any]]: + vectors: list[dict[str, Any]] = [] + for hop_index, domain in ((1, 31337), (2, 31338)): + profiles = [keccak(text=f"xir-go-parity-profile-{index}") for index in range(hop_index - 1)] + evidence_hashes = [ + keccak(text=f"xir-go-parity-evidence-{index}") for index in range(hop_index - 1) + ] + transitions = [ + keccak(text=f"xir-go-parity-transition-{index}") for index in range(hop_index - 1) + ] + current_profile = keccak(text=f"xir-go-parity-current-profile-{hop_index}") + current_transition = keccak(text=f"xir-go-parity-current-transition-{hop_index}") + inner = encode( + ["(bytes32,bytes32,bytes32[],bytes32[],bytes32[])"], + [ + ( + current_profile, + current_transition, + profiles, + evidence_hashes, + transitions, + ) + ], + ) + body = encode(["uint8", "bytes"], [3, inner]) + sender = bytes.fromhex("00" * 12 + "dd" * 20) + evidence = keccak(encode(["uint32", "bytes32", "bytes"], [domain, sender, body])) + vectors.append( + { + "hop_index": hop_index, + "domain": domain, + "sender": _hex(sender), + "current_profile": _hex(current_profile), + "current_transition": _hex(current_transition), + "prior_profiles": [_hex(value) for value in profiles], + "prior_evidence": [_hex(value) for value in evidence_hashes], + "prior_transitions": [_hex(value) for value in transitions], + "body": _hex(body), + "evidence_hash": _hex(evidence), + } + ) + return vectors + + +def build_document() -> dict[str, Any]: + payload_vectors = [] + for attempt in ATTEMPTS: + application, encoded = _application_payload(attempt, PAYLOAD_SCHEDULE) + payload_vectors.append( + { + **attempt, + "attempt_key": _hex(keccak(text=attempt["attempt_id"])), + "application_sha256": hashlib.sha256(application).hexdigest(), + "application_bytes": _hex(application), + "payload_encoding": _hex(encoded), + } + ) + bundle_steps = [] + bundle_record = _record(3) + bundle_context = _context(3) + for index in range(3): + receipt = _receipt(3, 1, bundle_record, bundle_context) + prefix = bundle_start(index) + bundle_steps.append( + { + "index": index, + "receipt_count": index, + "bundle_start": _hex(prefix), + "step": _hex(bundle_step(prefix, index, receipt)), + "profile_hash": _hex(receipt.profile_hash), + "evidence_hash": _hex(receipt.evidence_hash), + "transition_hash": _hex(receipt.transition_hash), + } + ) + return { + "schema_version": SCHEMA_VERSION, + "generator": { + "script": "scripts/generate_go_parity_vectors.py", + "python_source": "src/xir_lab/native", + "fixed_seed": FIXED_SEED, + }, + "constants": { + "registry_version": REGISTRY_VERSION, + "payload_schedule": PAYLOAD_SCHEDULE, + "fixed_seed": FIXED_SEED, + "fixture_keys": FIXTURE_KEYS, + "chain_ids": list(CHAIN_IDS), + "routes": list(ROUTE_ORDER), + }, + "profile_hashes": [ + { + "route": route, + "hop_index": hop_index, + "hash": _hex(multihop_profile_hash(route, hop_index)), + } + for route in ROUTE_ORDER + for hop_index in range(1, len(route) + 1) + ], + "gateway_typed_ids": [ + {"chain_id": chain_id, "kind": 1, "value": _hex(multihop_gateway_typed_id(chain_id)[1])} + for chain_id in CHAIN_IDS + ], + "adapter_keys": [ + { + "route": route, + "hop_index": hop_index, + "direction": direction, + "key": adapter_key(route, hop_index, direction), + } + for route in ROUTE_ORDER + for hop_index in range(1, len(route) + 1) + for direction in ("in", "out") + ], + "typed_ids": [ + { + "kind": 1, + "value": _hex(bytes.fromhex(f"{seed:040x}")), + "hash": _hex(keccak(bytes([1, 20]) + bytes.fromhex(f"{seed:040x}"))), + } + for seed in (1, 42) + ] + + [ + { + "kind": 2, + "value": _hex(bytes.fromhex(f"{seed:064x}")), + "hash": _hex(keccak(bytes([2, 32]) + bytes.fromhex(f"{seed:064x}"))), + } + for seed in (5,) + ], + "envelopes": _envelope_vectors(), + "bundle_steps": bundle_steps, + "payloads": payload_vectors, + "layerzero_packets": _packet_vectors(), + "hyperlane_evidence": _hyperlane_evidence_vectors(), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args() + document = build_document() + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"wrote {arguments.output} ({arguments.output.stat().st_size} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_go_e2e.sh b/scripts/prepare_go_e2e.sh new file mode 100755 index 0000000..1878bbb --- /dev/null +++ b/scripts/prepare_go_e2e.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build Solidity fixtures only; no Rust validators, deployment or remote RPCs. +set -euo pipefail +[[ $# -eq 1 && "$1" = /* ]] || { echo "usage: $0 " >&2; exit 2; } +runtime_root=$1 +repository_root=$(cd "$(dirname "$0")/.." && pwd) +lock="$repository_root/toolchain/native-protocol-stack.lock.json" +for executable in git jq forge anvil node corepack; do command -v "$executable" >/dev/null; done +# Never build through artifacts linked to another worktree. +for path in contracts protocol-projects/hyperlane-native protocol-projects/layerzero-native; do + for output in out cache lib; do + [[ ! -L "$repository_root/$path/$output" ]] || { + echo "refusing linked build directory: $path/$output" >&2; exit 1; + } + done +done +mkdir -p "$runtime_root/protocols" "$runtime_root/provenance" +for component in hyperlane layerzero-v2; do + destination="$runtime_root/protocols/$component" + repository=$(jq -er --arg id "$component" '.components[] | select(.component_id == $id) | .official_repository' "$lock") + commit=$(jq -er --arg id "$component" '.components[] | select(.component_id == $id) | .commit' "$lock") + if [[ ! -d "$destination/.git" ]]; then + mkdir -p "$destination" + git -C "$destination" init --quiet + git -C "$destination" remote add origin "$repository" + fi + [[ "$(git -C "$destination" remote get-url origin)" = "$repository" ]] + [[ -z "$(git -C "$destination" status --porcelain --untracked-files=all)" ]] + git -C "$destination" fetch --depth 1 origin "$commit" + git -C "$destination" checkout --detach "$commit" + [[ "$(git -C "$destination" rev-parse HEAD)" = "$commit" ]] + printf '%s %s\n' "$component" "$commit" +done +( + cd "$runtime_root/protocols/layerzero-v2" + corepack yarn install --immutable +) +forge build --root "$repository_root/contracts" +"$repository_root/scripts/prepare_hyperlane_project.sh" "$runtime_root" +"$repository_root/scripts/prepare_layerzero_project.sh" "$runtime_root" +for component in hyperlane layerzero-v2; do + [[ -z "$(git -C "$runtime_root/protocols/$component" status --porcelain --untracked-files=all)" ]] || { + echo "dependency preparation dirtied $component checkout" >&2; exit 1; + } +done diff --git a/scripts/run_go_e2e.sh b/scripts/run_go_e2e.sh new file mode 100755 index 0000000..32b5410 --- /dev/null +++ b/scripts/run_go_e2e.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Fail on missing fixtures, skipped tests, or an accidentally empty test selection. +set -euo pipefail +[[ $# -eq 1 && "$1" = /* ]] || { echo "usage: $0 " >&2; exit 2; } +log=$1 +repository_root=$(cd "$(dirname "$0")/.." && pwd) +command -v jq >/dev/null +mkdir -p "$(dirname "$log")" +cd "$repository_root/go-runtime" +XIR_REQUIRE_E2E=1 go test -count=1 -timeout=20m -json ./internal/runner -run '^(TestAnvil|TestReview)' | tee "$log" +jq -se ' + ([.[] | select(.Action == "skip" or .Action == "fail")] | length == 0) and + ([.[] | select(.Action == "pass") | .Test] as $passed | + ["TestAnvilRouteDeliversAndAppliesEffect", + "TestAnvilRestartResumesWithoutDuplicateDispatch", + "TestReviewLayerZeroDurations", "TestReviewRootFinalityRetry", + "TestReviewLayerZeroLateRestart", "TestReviewHyperlaneRetryAfterLaterDispatch", + "TestReviewLegacyRootRecovery", "TestReviewRootNonceReservation", + "TestReviewLayerZeroStageRestarts", "TestReviewLayerZeroExternalPolling", + "TestReviewHyperlaneSameBlockCheckpoint"] | + all(.[]; . as $test | $passed | index($test) != null)) +' "$log" >/dev/null || { echo "required Go E2E tests did not all run and pass (skip is failure)" >&2; exit 1; }