diff --git a/.claude/PRs.md b/.claude/PRs.md index c62f61a1..c69c6135 100644 --- a/.claude/PRs.md +++ b/.claude/PRs.md @@ -46,3 +46,5 @@ PRs raised through Claude Code. | [#234](https://github.com/ewitulsk/SuiOptions/pull/234) | [SO-237](https://suioptions.atlassian.net/browse/SO-237) | SO-19 Frontend | Group Vault Carousel by Asset with Vertical Cadence Coverflow | | [#251](https://github.com/ewitulsk/SuiOptions/pull/251) | [SO-253](https://suioptions.atlassian.net/browse/SO-253) | SO-8 Protocol | Migrate Keeper Realized-Vol to Cached BenchmarkVol (fix Pyth 429 storm) | | [#265](https://github.com/ewitulsk/SuiOptions/pull/265) | [SO-266](https://suioptions.atlassian.net/browse/SO-266) | SO-19 Frontend | Gate Exercise on Expired Options + Fix Off-Screen Popup Positioning | +| [#268](https://github.com/ewitulsk/SuiOptions/pull/268) | [SO-269](https://suioptions.atlassian.net/browse/SO-269) | — | Sponsor coinWithBalance coin Cleanup in All Gas-Station Templates | +| [#276](https://github.com/ewitulsk/SuiOptions/pull/276) | [SO-275](https://suioptions.atlassian.net/browse/SO-275) | SO-274 Cross-Chain Bridge | Bridge M0+M1 Foundation: Messaging Contracts, Locker, Signer & Relayer | diff --git a/.github/workflows/bridge-enclave.yml b/.github/workflows/bridge-enclave.yml new file mode 100644 index 00000000..557d8179 --- /dev/null +++ b/.github/workflows/bridge-enclave.yml @@ -0,0 +1,162 @@ +name: Bridge Enclave (Nautilus) + +# Build + measure the AWS Nitro Enclave image (EIF) for bridge-signer-service, +# and (on manual dispatch) deploy it to ECR. bridge_tickets/07. +# +# Runs on a FREE public-repo arm64 hosted runner (ubuntu-24.04-arm) — the EIF +# must be built natively on arm64 for the c7g.large (Graviton) host; x86+QEMU is +# avoided because emulation jeopardizes PCR reproducibility. +# +# CLEAN SEPARATION from the rest of CI: +# - dedicated file + name; its own concurrency group +# - path-filtered so it ONLY runs on enclave/signer changes (no overlap with +# move-ci.yml `contracts/**` or the deploy-*.yml stack) +# - does NOT call the shared _deploy.yml; its own ECR repo + (later) IAM role +# from the isolated `infra-bridge` terraform root, not the main infra. + +on: + workflow_dispatch: + inputs: + deploy: + description: 'Push the image to ECR + publish the EIF after building' + type: boolean + default: false + environment: + description: 'Target environment for deploy' + type: choice + options: [staging, prod] + default: staging + push: + branches: [main, ewitulsk/sui-bridge] + paths: &enclave_paths + - 'rust-backend/bridge-enclave/**' + - 'rust-backend/services/bridge-signer-service/**' + - 'rust-backend/crates/bridge-types/**' + - 'rust-backend/crates/bridge-signer/**' + - '.github/workflows/bridge-enclave.yml' + pull_request: + paths: *enclave_paths + +# Serialize per-ref; cancel superseded CI runs (but never a deploy). +concurrency: + group: bridge-enclave-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +permissions: + contents: read + id-token: write # OIDC → AWS, only used on deploy + +env: + # PCR0 depends on the nitro-cli / bundled-kernel version, NOT just the app + # image — pin it exactly and bump deliberately (a bump changes PCR0). + NITRO_CLI_VERSION: v1.3.1 + # ECR repo is created by the infra-bridge terraform root (ticket 07 Phase 5). + ECR_REPO: ${{ vars.BRIDGE_ENCLAVE_ECR_REPO }} + +jobs: + build-enclave: + runs-on: ubuntu-24.04-arm # free native arm64 on public repos (GA Aug 2025) + steps: + - uses: actions/checkout@v4 + + # --- build the reproducible app image (context = rust-backend workspace) --- + - name: Build enclave app image + working-directory: rust-backend + run: | + docker build \ + -f bridge-enclave/Dockerfile \ + -t bridge-signer-enclave:${{ github.sha }} \ + . + + # --- install nitro-cli (SMOKE-TEST POINT, ticket 07 Phase 2) --- + # build-enclave is a build/measure step and should not need Nitro hardware + # or the nitro_enclaves driver — but this is the thing to confirm first on + # a hosted runner. If it fails here, move the build-enclave step to a + # self-hosted Graviton runner (restricted to non-fork triggers) per README. + - name: Install nitro-cli (${{ env.NITRO_CLI_VERSION }}) + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential llvm-dev libclang-dev + git clone --depth 1 --branch "${NITRO_CLI_VERSION}" \ + https://github.com/aws/aws-nitro-enclaves-cli.git /tmp/nitro-cli + # Builds the nitro-cli binary + ships the arch-specific kernel/init blobs + # that build-enclave bundles into the EIF (blobs affect PCR0). + make -C /tmp/nitro-cli nitro-cli + # `make install` (install-tools) installs BOTH binaries; vsock-proxy + # must be built too or install dies on the missing binary. + make -C /tmp/nitro-cli vsock-proxy + # `make install` installs to the LOCAL prefix ./build/install (not + # /usr/bin); its env.sh sets PATH + NITRO_CLI_BLOBS. Source it in + # every step that runs nitro-cli. + sudo make -C /tmp/nitro-cli install + # The rpm/deb packages create this log dir; the from-source local + # install doesn't, and nitro-cli dies (E19) if it can't open it. + sudo mkdir -p /var/log/nitro_enclaves + sudo chown "$(id -u):$(id -g)" /var/log/nitro_enclaves + source /tmp/nitro-cli/build/install/etc/profile.d/nitro-cli-env.sh + nitro-cli --version + + # --- build the EIF + capture measurements --- + - name: Build EIF + run: | + source /tmp/nitro-cli/build/install/etc/profile.d/nitro-cli-env.sh + nitro-cli build-enclave \ + --docker-uri bridge-signer-enclave:${{ github.sha }} \ + --output-file signer.eif \ + > measurements.json + cat measurements.json + + # --- reproducibility gate: PCR0 must match the approved value --- + - name: Verify PCR0 + run: | + set -euo pipefail + PCR0=$(jq -r '.Measurements.PCR0' measurements.json) + EXPECTED=$(tr -d '[:space:]' < rust-backend/bridge-enclave/expected_pcr0.txt) + echo "built PCR0 = $PCR0" + echo "expect PCR0 = $EXPECTED" + if [ "$EXPECTED" = "PENDING" ]; then + echo "::notice::expected_pcr0.txt is PENDING — first-run capture. Commit this PCR0 to enable the drift gate:" + echo "::notice::$PCR0" + elif [ "$PCR0" != "$EXPECTED" ]; then + echo "::error::PCR0 drift — built EIF does not match the approved measurement. The on-chain EnclaveConfig would reject this build." + exit 1 + else + echo "PCR0 matches the approved value." + fi + + - name: Upload EIF + measurements + uses: actions/upload-artifact@v4 + with: + name: signer-eif-${{ github.sha }} + path: | + signer.eif + measurements.json + retention-days: 14 + + # --- deploy (manual dispatch only) --- + - name: Configure AWS credentials + if: github.event_name == 'workflow_dispatch' && inputs.deploy + uses: aws-actions/configure-aws-credentials@v4 + with: + # Dedicated bridge deploy role (infra-bridge root) — the main + # DEPLOY_ROLE_ARN's OIDC trust only covers staging/main refs. + role-to-assume: ${{ vars.BRIDGE_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Login to ECR + if: github.event_name == 'workflow_dispatch' && inputs.deploy + uses: aws-actions/amazon-ecr-login@v2 + id: ecr + + - name: Push image to ECR + if: github.event_name == 'workflow_dispatch' && inputs.deploy + run: | + set -euo pipefail + REG="${{ steps.ecr.outputs.registry }}" + docker tag bridge-signer-enclave:${{ github.sha }} "$REG/$ECR_REPO:${{ github.sha }}" + docker push "$REG/$ECR_REPO:${{ github.sha }}" + echo "pushed $REG/$ECR_REPO:${{ github.sha }}" + # NOTE: the enclave HOST runs the measured EIF directly (Model B, ship + # the EIF) rather than rebuilding — publish signer.eif to S3 for the + # host here once the infra-bridge bucket exists (ticket 07 Phase 5). diff --git a/BRIDGE_CONTEXT.md b/BRIDGE_CONTEXT.md new file mode 100644 index 00000000..7372ed16 --- /dev/null +++ b/BRIDGE_CONTEXT.md @@ -0,0 +1,163 @@ +# Bridge enclave — session context (2026-07-03) + +Working state of ticket 07 (`bridge_tickets/07-nautilus-enclave.md`, SO-275) after the +2026-07-03 session: the Nitro host is live, the CI build/deploy pipeline works end-to-end, +and the stock-nautilus validation (Phase 1 step 1) passed — including on-chain attestation +verification against the Phase 3 registry. This doc records every artifact, credential +surface, and gotcha so work can resume cold. + +**Phase status:** P2 (CI) ✅ · P3 (registry) ✅ · P5 (terraform) ✅ APPLIED · P1 step 1 +(stock validation) ✅ → **next is the real P1 work** (vendor + library-fy + vsock/TLS +egress rework), then P4, P6. + +--- + +## 1. Infrastructure (terraform applied — the host is LIVE) + +`rust-backend/infra-bridge/` applied 2026-07-03 with zero impact on existing infra +(plan verified additive-only; `options-prod-host` health-checked before/during/after). + +| Resource | Value | +|---|---| +| EC2 instance | `i-0ad5124712c538893` — c7g.large (Graviton, 2 vCPU / 4 GB), AL2023 arm64, `enclave_options` enabled, IMDSv2 | +| IPs | private `10.40.1.142`, public `54.144.200.244` (us-east-1, shared `options-vpc` / `options-public-0`) | +| Access | **SSM only, no SSH**: `aws ssm start-session --target i-0ad5124712c538893 --region us-east-1` | +| ECR repo | `502186568577.dkr.ecr.us-east-1.amazonaws.com/options-bridge-signer-enclave` (immutable tags) | +| Host IAM | role/profile `options-bridge-enclave` (SSM core + scoped ECR pull) | +| CI IAM | role `options-bridge-gh-deploy` (see §2) | +| Security group | `sg-091a8455c990ff511` — egress all; ingress tcp/3000 only via `signer_api_ingress_cidrs` (currently empty) | +| Allocator | 1 vCPU / 1536 MiB (`/etc/nitro_enclaves/allocator.yaml`), nitro-cli 1.4.4 + docker verified active | + +**Caveats** +- Terraform state is **LOCAL to Evan's machine** (gitignored). No S3 backend yet — no + other machine can manage these resources. Add a `backend "s3"` block when this stops + being a one-person root. +- The root is deliberately isolated from `rust-backend/infra/` (which has a known + destructive-drift landmine). Blanket `terraform apply` in `infra-bridge/` is safe; + it reads the VPC/subnet via data lookups only. +- `ignore_changes = [ami]` pins the host against AL2023 AMI-release replacement. + +## 2. CI pipeline (`.github/workflows/bridge-enclave.yml`) — working + +First fully successful deploy run: [28686106945](https://github.com/ewitulsk/SuiOptions/actions/runs/28686106945) +(dispatch with `deploy=true`, env staging). Pushed image +`options-bridge-signer-enclave:8f8a6d8f13f2f36eed154af79cc3eaffc47ea192` +(digest `sha256:f61659dcde586e5f7260b151dd52ca505a4b8571e5fa948b5f4198c8efb2ab28`). + +Run it: `gh workflow run bridge-enclave.yml --ref -f deploy=true -f environment=staging`. +Watch out: a push to enclave paths auto-triggers a build-only run in the same +concurrency group — cancel it before dispatching or the dispatch queues behind it. + +**Four fixes were needed (all committed on `ewitulsk/sui-bridge`):** +| Commit | Fix | +|---|---| +| `c8f729d` | `make vsock-proxy` before `make install` — install-tools installs BOTH binaries; only nitro-cli was being built | +| `36853cd` | source nitro-cli's `env.sh` in every step that runs it — from-source install lands in a local prefix (`./build/install`), not `/usr/bin` | +| `f71aa1f` | pre-create `/var/log/nitro_enclaves` — rpm/deb installs create it, from-source doesn't (nitro-cli dies E19) | +| `8f8a6d8` | assume dedicated `BRIDGE_DEPLOY_ROLE_ARN` — the shared `DEPLOY_ROLE_ARN`'s OIDC trust only covers `refs/heads/{staging,main}` and its ECR allowlist lacks the bridge repo | + +**Repo variables (set):** +- `BRIDGE_ENCLAVE_ECR_REPO` = `options-bridge-signer-enclave` +- `BRIDGE_DEPLOY_ROLE_ARN` = `arn:aws:iam::502186568577:role/options-bridge-gh-deploy` + — defined in `infra-bridge/iam_ci.tf`; OIDC trust covers `ewitulsk/sui-bridge`, + `staging`, `main` refs; carries the scoped ECR-push policy. The shared + `options-gh-actions-deploy` role was returned to its exact pre-session state. + +**Answered smoke-test question (ticket P2):** `nitro-cli build-enclave` DOES work on the +free hosted `ubuntu-24.04-arm` runner — no Nitro hardware or self-hosted Graviton runner +needed for build/measure. + +**PCR0 drift gate is deliberately still `PENDING`** (`rust-backend/bridge-enclave/expected_pcr0.txt`): +- the scaffold Dockerfile `COPY . .`s the whole `rust-backend/` workspace, so ANY + workspace change shifts PCR0 (two CI runs → two different PCR0s, as expected); +- it self-documents as not-yet-bit-reproducible (base images by tag, no + `SOURCE_DATE_EPOCH`); +- `expected_pcr0.txt` sits inside the docker context → committing a value is circular. + +Arm the gate as part of the P1/P2 reproducibility work, not before. + +## 3. Stock-nautilus validation (Phase 1 step 1) — PASSED + +Ran upstream `MystenLabs/nautilus` @ **`af7535b9d314f034fa7f9f1f208264540d54cde1`** +(pin this in `FORK_DELTA.md` when vendoring) end-to-end on the box: + +1. **Build (on-box):** upstream's reproducible Containerfile/Makefile is **amd64-ONLY** + (x86_64 StageX digests, x86 `bzImage`, hardcoded `--platform linux/amd64`), so + validation used a plain aarch64 Dockerfile (rust:1.90-slim → bookworm-slim, stock + `nautilus-server` weather-example + its `run.sh`) + `nitro-cli build-enclave` on the + host. EIF measurements: PCR0 `25bdd759b0906c3703d0e0dd3a907a6cfc4330a9e20ee025bf13cce7f76ff7eaae691bb8717304b4c968c64b25320485`. +2. **Boot:** `nitro-cli run-enclave --cpu-count 1 --memory 1024` → enclave up (CID 16). + Secrets handshake over vsock 7777 (`{"API_KEY":"dummy-validation-key"}` via socat, as + upstream `expose_enclave.sh` does), parent-side `socat TCP4-LISTEN:3000 ↔ VSOCK` + forward, `GET /` → `Pong!`, `/get_attestation` → 4492-byte COSE_Sign1 doc. +3. **On-chain (testnet, deployer `0xab8d…4865`):** published the Phase 3 package and + verified the real attestation path that Move unit tests couldn't cover: + +| Object | ID | +|---|---| +| Package `bridge_enclave` | `0xeda4ddd012c724e1fdcf8c69abdf3d365a6b52448846ccf8098d011e037cc466` | +| `Cap` | `0x5c7fae89626c96ad3ffcac5fbea823461196189bef03c2fb6df2ecc111dfd828` | +| `UpgradeCap` | `0xf4fc772617e4d61850f194ce113daeb830cddb7043110b7d8ddfadd4773902e8` | +| `EnclaveConfig` (validation PCRs) | `0x1b00efcab7113f978fb50a12564778df8fe68c974559e69780ead21722e76ec7` | +| Registered `Enclave` (shared) | `0xeb33c0a8f532f29ca91bde92c69bb42291ee892ea1f08dc690db19573b5911ae` | +| `register_enclave` tx | `HnS4TQz1bHutYB3XCFjALHRvG6HdkX3MMZctcVZ9gYuM` | + +The chain verified the doc's signature chain to the AWS Nitro root CA, matched PCRs +against the config, stored the enclave's boot-generated ephemeral pubkey, and emitted +`EnclaveRegistered`. Registration PTB shape (from upstream `register_enclave.sh`): + +``` +sui client ptb \ + --assign v "vector[]" \ + --move-call "0x2::nitro_attestation::load_nitro_attestation" v @0x6 \ + --assign doc \ + --move-call "$PKG::enclave::register_enclave<$PKG::signer::BRIDGE_SIGNER>" @$CONFIG doc +``` + +**Box state as left:** validation enclave may still be running (dummy key, nothing +sensitive) — `nitro-cli terminate-enclave --all` via SSM to kill. Upstream clone at +`/root/nautilus`, EIF at `/root/nautilus-validation.eif`, measurements at +`/var/tmp/nautilus-measurements.json`, attestation at `/var/tmp/attestation.json`. +The validation `EnclaveConfig`/`Enclave` objects are throwaway — a real config (fresh +PCRs, real name) supersedes them when the signer EIF exists. + +**Operational gotchas hit:** +- `nitro-cli build-enclave` needs `NITRO_CLI_ARTIFACTS` (or `HOME`) set → E51 otherwise + (SSM RunShellScript sessions have neither). +- SSM `get-command-invocation` truncates output (~24KB) — pull large files (e.g. the + 9KB attestation hex) in chunks. +- SSM shell mangles multi-line scripts passed as parameters — ship them base64-encoded. +- The box has 1 usable parent vCPU; on-box cargo builds are slow (~30 min for the + validation image). Fine for validation; real builds belong in CI. + +## 4. What's next (remaining ticket 07 work) + +1. **Phase 1 (the crux, ~1–2 wk):** + - Vendor the pinned nautilus subtree into `rust-backend/bridge-enclave/` + (`src/nautilus-server`, vsock forwarder, `allowed_endpoints.yaml`) + `FORK_DELTA.md` + recording `af7535b9…` and deltas. Own `Cargo.lock`, NOT a main-workspace member. + - Library-fy `bridge-signer-service` (expose router + `AppState` as a lib crate); + nautilus-server app pulls it by path dep. + - Egress rework: route `EvmProbe`/`SuiProbe`/`SuiClientBuilder` HTTPS through + vsock→parent-forwarder with **rustls terminating in-enclave** (pinned provider certs). +2. **Phase 2 completion:** arm64 reproducible build (upstream StageX pipeline is + amd64-only — port it or pin our Dockerfile by digest + `SOURCE_DATE_EPOCH`), then + commit the real PCR0 and arm the drift gate. +3. **Phase 4:** ticket-02 `RpcVerifier` in-enclave over the vsock/TLS transport. +4. **Phase 6:** lifecycle scripts + boot flow (enclave boots → attest → operator + registers on-chain → signer flips ready; signer refuses `/sign_requests` until + its key is registered). +5. Module-ize the terraform for N=3 (ticket 09) and add the S3 state backend. + +## 5. Session artifact inventory + +- **Committed this session:** CI fixes + OIDC role (`c8f729d`, `36853cd`, `f71aa1f`, + `8f8a6d8`), `enclave/Move.lock` + `Published.toml` (testnet publish record), this doc. +- **AWS (all in `infra-bridge` terraform state except noted):** instance, SG, host + role/profile, ECR repo, CI role `options-bridge-gh-deploy` + its inline policy. + Nothing outside this list was modified; `options-gh-actions-deploy` briefly carried a + scoped bridge-ECR policy mid-session and was restored to its original state. +- **Testnet:** the five objects in §3 (throwaway validation config/enclave; package + + caps are real). +- **On the box (ephemeral):** `/root/nautilus`, `/root/nautilus-validation.eif`, + `/var/tmp/{nautilus-*,attestation.json,run.sh,setup.sh}`. diff --git a/bridge-spec.md b/bridge-spec.md new file mode 100644 index 00000000..1e0cf0f9 --- /dev/null +++ b/bridge-spec.md @@ -0,0 +1,421 @@ +# Cross-Chain Messaging Layer + Lock-and-Mint Bridge — Implementation Spec + +**Version:** 0.2 (architecture draft — revised after design review; Nautilus/Seal mechanics verified against MystenLabs docs and `seal_policy.move`) +**Scope of v1:** HyperEVM testnet ⇄ Sui testnet, single enclave (1-of-1, architected for k-of-n), Mysten open Seal testnet key servers. +**Status:** Specification only. No production code in this document. + +--- + +## 0. Reading guide + +Two facts define the roles of the cryptographic components in this design: + +- **Seal encrypts; it does not sign.** Seal's role is to encrypt each signer's key share at rest so that share can be decrypted only inside an attested Nautilus enclave running approved code. The signer is the **Nautilus TEE(s)** running a threshold-signature protocol. +- **The signing key is produced by Distributed Key Generation (DKG).** Participants run a multi-round protocol, each ends holding a *verified share*, the group public key falls out, and the full private key never exists anywhere — including inside any single TEE. + +The system has **three layers**, strictly separated: + +- **Layer 1 — Generic cross-chain messaging.** Seal+Nautilus threshold signers are the transport. Carries arbitrary payloads addressed to destination apps. +- **Layer 2 — Bridge app (Locker).** An NTT/OFT-style, one-deployment-per-asset lock-and-mint application built *on top of* Layer 1. Knows nothing about enclaves or signatures. +- **Layer 3 — Clients & relayers.** Fully untrusted plumbing that ferries self-verifying signed messages on-chain. + +--- + +## 1. Trust model (read this before any component) + +| Property | Guarantee | Depends on | +|---|---|---| +| **Message authenticity** | A destination Inbox accepts a message only if it carries a valid aggregated threshold signature from the registered signer group. | Threshold crypto (k-of-n), NOT the TEE. | +| **Share confidentiality** | No party (operator, host, attacker) can read a signer's key share at rest or in use. | Seal policy (PCR-gated) + Nautilus enclave memory isolation. | +| **Honest-code attestation** | A signer only participates if peers verify its Nautilus attestation (PCRs) and on-chain-registered pubkey. | Nautilus remote attestation + on-chain Enclave registry. | +| **No key reconstruction** | The full private key is never assembled anywhere, including at signing time. | FROST / GG20 threshold signing. | +| **Liveness** | Bridge progresses as long as ≥ k signers are online and ≥1 relayer is willing. | k-of-n availability; permissionless relay. | +| **Replay safety** | Each message executes at most once on the destination, on this deployment only. | Consumed-hash set on Inbox + per-deployment domain separator in the digest (§2.2, §2.6). | +| **Source truth** | A release/mint is signed only for messages a canonical Outbox committed at source finality. | Outbox commitment + per-chain finality gate. | +| **Chain-view integrity** | A signer's view of "committed at finality" cannot be forged by its own untrusted host. | TLS terminated inside the enclave + ≥2 independent RPC providers (§5.4). At N ≥ 3 the threshold also absorbs single-host MITM. | +| **Per-node share isolation** | A key share is decryptable only by the specific registered node instance — never by any enclave that merely runs the same code. | Per-node Seal policy binding (§6.5). PCR-only policies forbidden. | +| **Key-server honesty** | Share confidentiality at rest holds only while fewer than t of the chosen Seal key servers are compromised. | t-of-n Seal key-server selection (§6.6). | + +**TEE role.** Under threshold signing with no reconstruction, the TEE is **defense-in-depth**, not the trust root. Even a fully compromised single enclave cannot forge a group signature unless the attacker also controls ≥ k shares. At the **N=1 launch** this distinction collapses (1-of-1 ≡ a single signer ≡ trust the one TEE+Seal); the strong guarantee arrives only at **N ≥ 3, k = 2**. State this plainly to stakeholders: *launch security = single TEE + its host's network path; threshold security = a later phase turn-on.* The system-wide security ceiling is **min(k honest signer operators, t honest Seal key-server operators, integrity of each signer's chain view)** — the cryptography makes these thresholds enforceable; only operational independence makes them real. + +--- + +## 2. Layer 1 — Generic Cross-Chain Messaging + +### 2.1 Components + +``` +Source chain Off-chain Destination chain +┌──────────────┐ commit at ┌──────────────────┐ deliver ┌──────────────┐ +│ Outbox │─── finality ──────▶│ Signer group │────────▶│ Inbox │ +│ (per chain) │ (event/root) │ (N Nautilus TEEs │ (relay) │ (per chain) │ +└──────────────┘ │ + Seal shares) │ └──────────────┘ + ▲ └──────────────────┘ │ + │ send(dst, app, payload) │ receive() +┌──────────────┐ ┌──────────────┐ +│ App (Locker) │ │ App (Locker) │ +└──────────────┘ └──────────────┘ +``` + +- **Outbox** (one per chain): apps call it to emit a message; it assigns a nonce, computes the canonical message hash, and commits it (event + optional accumulator root) so the signer group can observe it deterministically. +- **Signer group** (N Nautilus enclaves): each holds a Seal-encrypted share; on request, independently verifies that a registered Outbox committed the message at source finality (§5.3–§5.4); runs threshold signing; emits one aggregated signature per message. +- **Inbox** (one per chain): verifies the aggregated signature against the registered group public key for the message's signature scheme, enforces nonce + hash dedup, and dispatches the payload to the destination app. + +### 2.2 Canonical message format (chain-neutral) + +The signers sign over a **canonical serialization** that is identical regardless of source/destination encoding. Per-chain adapters encode/decode but never change semantics. + +``` +CrossChainMessage { + version: u8 // format version, start at 1 + src_chain_id: u32 // internal registry ID (NOT the native chainid) + dst_chain_id: u32 // internal registry ID + nonce: u64 // uniqueness salt, assigned monotonically per (src_chain_id, dst_chain_id); no ordering semantics (§2.6) + src_app: bytes32 // sender app address, left-padded + dst_app: bytes32 // recipient app address, left-padded + payload: bytes // opaque to Layer 1; app-defined +} + +DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deployment_salt) // deployment_salt fixed at genesis, unique per deployment +message_hash = keccak256(DOMAIN_SEP || canonical_bcs_or_abi(CrossChainMessage)) +``` + +Design notes: +- **Internal chain IDs**, not native chain IDs, so chains without an EVM-style chainid (Sui) fit uniformly. A registry maps `internal_id ⇄ {native identifier, Outbox addr, Inbox addr, finality params}`. +- `bytes32` addresses on both sides; Sui object/package IDs are 32 bytes, EVM addresses are left-padded to 32. Generic for HyperEVM+Sui today, room for others. +- The **hash function is fixed to keccak256** for both chains (cheap on EVM; available in Move) so the signed digest is identical everywhere. +- **Domain separation is mandatory.** The digest binds a per-deployment salt. Without it, redeploying an Inbox (fresh `consumed` set, same registry IDs — which *will* happen on testnet) would let every previously signed message replay against the new deployment; it also rules out any cross-context reuse of group-key signatures. +- `payload` is fully opaque to Layer 1 — this is what makes it a *generic* messaging layer rather than a bridge-specific one. + +### 2.3 Signature schemes (dual, scheme-tagged) + +The delivered message carries a `(scheme_tag, group_pubkey_id, aggregated_signature)` envelope. The Inbox selects the verifier by `scheme_tag`: + +| Destination family | Scheme | On-chain verification | Group key | +|---|---|---|---| +| EVM (HyperEVM) | **GG20 / CGGMP threshold ECDSA (secp256k1)** | `ecrecover` → compare to registered group address | secp256k1 group key | +| Sui (and other Ed25519 chains) | **FROST threshold Schnorr (Ed25519)** | Ed25519 verify against registered group pubkey | Ed25519 group key | + +This requires **two DKGs** (one per curve) producing two group keys, both share-held, both Seal-gated. The envelope's `group_pubkey_id` lets the Inbox look up which registered key to check, enabling key rotation without ABI changes. + +### 2.4 Outbox interface (abstract; both chains implement) + +``` +send(dst_chain_id: u32, dst_app: bytes32, payload: bytes) -> (nonce: u64, message_hash: bytes32) + - caller = src_app (recorded) + - assigns nonce = next_nonce[dst_chain_id]++ + - emits MessageCommitted(message_hash, full CrossChainMessage fields) + - reverts if Outbox is paused + +view: next_nonce(dst_chain_id) -> u64 +view: is_committed(message_hash) -> bool +admin: setPaused(bool) // guardian only — global circuit breaker for this chain's outbound +``` + +**Per-family caller identity (`src_app`):** +- **EVM:** `src_app = msg.sender`, left-padded to 32 bytes. +- **Sui:** packages have no `msg.sender`. The Outbox issues an **`EmitterCap`** object per app at registration; `send` takes `&EmitterCap` and records `src_app = the cap's ID`. (Wormhole-on-Sui emitter pattern.) + +### 2.5 Inbox interface (abstract; both chains implement) + +``` +receive(message: CrossChainMessage, envelope: SignatureEnvelope) + 1. require !paused + 2. recompute message_hash from message (with DOMAIN_SEP) + 3. require message.dst_chain_id == THIS_CHAIN_ID + 4. require !consumed[message_hash] // hash dedup — the sole exactly-once guard (§2.6) + 5. verify envelope against registered group key for envelope.scheme_tag + 6. consumed[message_hash] = true + 7. deliver payload to message.dst_app (per-family, below) + 8. emit MessageDelivered(message_hash) + - relayer is untrusted: all checks are self-contained on-chain + +admin: setPaused(bool) // guardian only +admin: registerGroupKey(scheme_tag, key) // governance — supports rotation +admin: setSignerThreshold(k, n) // governance +``` + +**Per-family delivery (step 7):** +- **EVM:** the Inbox calls `IMessageRecipient(dst_app).onReceive(src_chain_id, src_app, payload)` directly — dynamic dispatch exists. +- **Sui:** Move has **no dynamic dispatch**; the Inbox cannot call an arbitrary `dst_app`. Delivery inverts: `receive` verifies and returns a **hot-potato receipt** `DeliveredMessage { src_chain_id, src_app, dst_app, payload }` (no `drop`/`store` abilities). The relayer's PTB must, in the same transaction, pass it to the destination app's own entry function (e.g. `locker::consume(receipt, …)`), which asserts `receipt.dst_app == its own registered identity` before effecting. The hot potato makes verification and consumption atomic — the receipt cannot be stored, dropped, or smuggled out. (This is the Wormhole-on-Sui VAA pattern.) + +### 2.6 Dedup policy (no ordering — resolved) + +- **The Inbox enforces no cross-message ordering.** The nonce exists only to make otherwise-identical messages (same route, apps, payload) hash-distinct; the Outbox assigns it monotonically per `(src_chain_id, dst_chain_id)`, but the Inbox never checks sequence. +- **Hash dedup**: `consumed[message_hash]` is the sole exactly-once guard. +- Rationale: transfers are independent, so ordering buys nothing. Strict ordering turns the permissionless Outbox into a DoS lever (anyone can `send()` garbage that must then be delivered in order before real transfers land), and one undeliverable message wedges the whole lane. Dedup-only is the Wormhole model and is strictly simpler than a windowed seen-set. Apps that ever need ordering can sequence in their own payloads. + +### 2.7 Delivery-failure semantics + +- **Failure is atomic and retryable.** `consumed[message_hash]` is set in the same transaction as the app effect on both chains (EVM: a reverting `onReceive` reverts the whole `receive`; Sui: an aborting `consume` aborts the whole PTB, hot potato included). A failed delivery therefore leaves the message unconsumed and indefinitely retryable — no message is ever half-delivered. +- **A stuck message harms only itself.** Because the Inbox enforces no ordering (§2.6), an undeliverable message (malformed payload, unregistered peer, paused Locker) blocks nothing else on the lane. +- **v1 accepts permanently stuck messages.** There is no Layer 1 refund/recovery path: the only v1 sender is the Locker, which constructs payloads by code, so a permanently undeliverable message implies a bug — handled by governance (peer re-registration, contract upgrade), not protocol machinery. If richer recovery is ever needed, a "verify-and-store, execute separately" split (Wormhole-style) can be added without changing the message format. + +### 2.8 Pausing (Layer 1 circuit breaker) + +Both Outbox and Inbox are independently pausable by a **guardian** (multisig/governance). Pausing the Inbox halts *all inbound delivery* on that chain; pausing the Outbox halts *all outbound*. This is the global kill switch, distinct from per-asset Locker pausing (§3.5). + +--- + +## 3. Layer 2 — Lock-and-Mint Bridge (NTT/OFT-style) + +### 3.1 Model + +One Locker deployment **per asset per chain**, mirroring Wormhole NTT "manager" + transceiver and LayerZero OFT. Layer 1 plays the **transceiver** role; the Locker is the **manager**. + +- **Home chain** (where the asset is native): Locker is a **lock/escrow vault**. +- **Foreign chain**: Locker controls a **wrapped asset** with mint/burn authority. + - Sui: wrapped `Coin` whose `TreasuryCap` is held inside the shared Locker object (packages cannot own objects on Sui). Each asset's wrapped coin needs its own one-time-witness package, so onboarding an asset on Sui = publish a small coin package + hand its cap to a new Locker — a publish, not a config call. Factor this into the asset-onboarding runbook. + - HyperEVM: wrapped ERC-20 where the Locker holds mint/burn rights. +- **Invariant** (per asset per route): `wrapped_supply_on_foreign ≤ locked_collateral_on_home`. Enforced by construction: foreign mint happens only on a delivered burn-or-lock message; home release only on a delivered burn message. + +### 3.2 Bridge-to-Sui flow (HyperEVM → Sui), lock-and-mint + +``` +1. User calls Locker(HyperEVM).lock(amount, sui_recipient) +2. Locker escrows `amount`, builds payload = LockMsg{asset_id, amount, recipient=sui_recipient} +3. Locker calls Outbox(HyperEVM).send(dst=Sui, dst_app=Locker(Sui), payload) +4. Outbox commits message at HyperEVM finality (confirmation depth, §4) +5. Signer group observes committed message → threshold-signs with FROST-Ed25519 (dst=Sui) +6. Any relayer submits one PTB calling Inbox(Sui).receive(message, envelope) +7. Inbox(Sui) verifies Ed25519 group sig + hash dedup, returns hot-potato DeliveredMessage (§2.5) +8. Same PTB: Locker(Sui).consume(receipt) asserts dst_app == self, mints wrapped Coin + to sui_recipient (or queues the transfer if the rate limit is exceeded, §3.5) +``` + +### 3.3 Bridge-from-Sui flow (Sui → HyperEVM), burn-to-release + +``` +1. User calls Locker(Sui).burn(wrapped_coin, evm_recipient) +2. Locker(Sui) burns wrapped via TreasuryCap, builds payload = BurnMsg{asset_id, amount, recipient=evm_recipient} +3. Locker(Sui) calls Outbox(Sui).send(dst=HyperEVM, dst_app=Locker(HyperEVM), payload) +4. Outbox commits at Sui checkpoint finality +5. Signer group threshold-signs with GG20 ECDSA (dst=HyperEVM, ecrecover-compatible) +6. Any relayer calls Inbox(HyperEVM).receive(message, envelope) +7. Inbox(HyperEVM) ecrecovers group address + hash dedup, calls Locker(HyperEVM).onReceive +8. Locker(HyperEVM).onReceive(payload) releases escrowed native to evm_recipient + (or queues the transfer if the rate limit is exceeded, §3.5) +``` + +### 3.4 Locker (app) interface + +``` +// outbound +lock(amount, dst_recipient: bytes32) // home chain +burn(amount, dst_recipient: bytes32) // foreign chain + +// inbound +onReceive(src_chain_id, src_app, payload) // EVM: called only by the local Inbox +consume(receipt: DeliveredMessage, ...) // Sui: consumes the Inbox hot potato (§2.5) + - require caller == Inbox (EVM) / receipt originates from the local Inbox (Sui) + - require src_app == registered peer Locker for this asset + - decode {asset_id, amount, recipient} + - if within rate limit: home → release escrow to recipient; foreign → mint wrapped to recipient + - else: enqueue {recipient, amount, unlock_at} — never revert (§3.5) + +claim(queued_transfer_id) // permissionless: releases a queued transfer + // once its unlock time has passed + +// admin +setPaused(bool) // per-asset guardian +setPeer(chain_id, locker_addr) // governance: trusted sibling Locker +setRateLimit(window, cap) // governance (recommended default ON) +``` + +### 3.5 Emergency controls (two independent levels) + +- **Per-asset halt**: pause one Locker → stops that asset only. Free from the one-deployment-per-asset model. +- **Layer 1 global halt**: pause Outbox/Inbox → stops *all* messaging on a chain. +- **Per-asset rate limits (default ON — resolved).** NTT treats outbound+inbound rate-limiting as core. A capped-per-window limit is the cheapest insurance that a signer-key compromise can't drain everything in one transaction. +- **Rate-limit overflow queues; it never reverts.** By the time the Locker runs, the message is consumed at the Inbox (or the whole tx reverts and the lane retries forever). Reverting on an exceeded limit would strand the user's funds at source with no recovery path until the window resets. Instead, NTT-style: record the transfer in an on-chain queue with an unlock time; `claim` is permissionless after the window. Delivery always succeeds; only the payout is delayed. + +--- + +## 4. Finality handling (per chain) + +The enclave signs **only after source finality**: + +- **HyperEVM**: configurable **confirmation depth**. ⚠️ **Open item to verify at build time:** HyperEVM is the EVM execution layer of Hyperliquid (HyperBFT/HyperCore consensus), not a generic PoW/PoS EVM — its finality semantics and reorg behavior must be confirmed against current Hyperliquid docs before fixing the confirmation parameter. Do not assume Ethereum-style finality. Also confirm the **dual-block architecture** (frequent small blocks vs ~once-a-minute big blocks with separate gas limits): it affects both what "confirmation depth" means and which block type Inbox/Locker transactions land in. +- **Sui**: wait until the source transaction is in a **finalized checkpoint** (Sui has fast deterministic finality; effectively no reorgs once checkpointed). +- No optimistic path in v1 (keeps the trust model clean). + +The finality parameters live in the chain registry (§2.2) so they are tunable per chain without code changes. + +--- + +## 5. The signer node (Nautilus enclave application) + +### 5.1 Base + +Fork **`MystenLabs/nautilus`**, app at `src/nautilus-server/src/apps/seal-example`. That example already implements: PCR-gated Seal key-load (2-step host-delegated fetch), in-enclave key caching, an Ed25519 ephemeral key registered on-chain in an `Enclave` object, and signed response envelopes. We replace the "weather API key" provisioning with **threshold-signing-share** provisioning, replace `/process_data` with bridge-message signing, and replace the example Seal policy outright — the stock `seal_policy.move` authorizes *any* registered enclave with matching PCRs, which is unsafe for key shares (§6.5). + +### 5.2 Keys held inside the enclave (in memory only) + +| Key | Type | Purpose | +|---|---|---| +| Ephemeral key | Ed25519 | Signs the Seal `seal_approve` PTB intent + authenticates to peers; registered on-chain (per Nautilus pattern). | +| Seal wallet | Ed25519 | Seal certificate signing + tx sender for `seal_approve`. | +| ElGamal enc key | BLS group elems | Decrypts Seal key-load responses inside the enclave. | +| **ECDSA share** | secp256k1 (GG20/CGGMP) | This node's share of the EVM-destined group key. **Seal-encrypted at rest.** | +| **Ed25519 share** | Ed25519 (FROST) | This node's share of the Sui-destined group key. **Seal-encrypted at rest.** | + +Only the **shares** are Seal-stored. The group private keys are never stored or reconstructed. + +### 5.3 Endpoints + +**Public (port 3000):** +``` +GET /get_attestation // Nautilus attestation doc (PCRs, eph pubkey) +POST /sign_requests // {message: CrossChainMessage} → 202 + request accepted + // idempotent per message_hash; cheap pre-check that the + // hash is committed on a registered Outbox before queueing +GET /sign_requests/{message_hash} // pending | signed {envelope} | rejected {reason} +GET /health +``` + +Signing is **request-triggered and asynchronous**: FROST/GG20 at k > 1 are multi-round protocols across nodes, so a synchronous request/response API cannot survive M3 — design the poll model now so the interface doesn't break when MPC turns on. One signing session per `message_hash`, never per request. The endpoint is public and therefore a DoS surface: reject anything not already committed on a registered Outbox *before* doing expensive work, dedupe in-flight hashes, and rate-limit per source. + +**Peer-to-peer (MPC mesh, authenticated):** +``` +MPC round transport (DKG + signing rounds) — libp2p, see §6.3 +``` + +**Admin (port 3001, localhost on the EC2 host only):** +``` +POST /admin/init_seal_key_load // returns encoded FetchKeyRequest (per Nautilus-Seal) +POST /admin/complete_seal_key_load // caches decrypted Seal keys in enclave memory +POST /admin/provision_ecdsa_share // load Seal-encrypted secp256k1 share +POST /admin/provision_ed25519_share // load Seal-encrypted Ed25519 share +POST /admin/dkg/start // begin a DKG round (ceremony, §6) +``` + +### 5.4 What the enclave checks before signing (the security boundary) + +For each `/sign_message`: +1. Verify the message was **committed by the registered Outbox** on the named source chain (read via the enclave's own trusted full-node/RPC view). +2. Verify **source finality** per §4. +3. Verify the message is **well-formed** and `dst_chain_id` is a registered route. +4. Only then enter the threshold-signing round. + +This is the narrow, auditable check: "did the canonical Outbox commit this exact message at finality," not free-form event scraping. + +**The chain view is part of the security boundary.** Nautilus enclaves have no direct network: all traffic is forwarded by the untrusted parent EC2 host (vsock + `allowed_endpoints.yaml`). A host that can MITM the enclave's RPC reads can fabricate "committed at finality" and get an arbitrary message signed — no enclave compromise needed. Therefore: + +1. **TLS terminates inside the enclave**, pinned to named RPC providers; the host forwards opaque bytes only. +2. Commitment + finality are confirmed against **≥ 2 independent RPC providers** before signing. +3. At N = 1 the host's network path is squarely in the TCB (stated in the §1 table); at N ≥ 3 an attacker must MITM k independent operators' hosts, so the threshold absorbs a single bad host. + +--- + +## 6. The DKG ceremony & threshold signing + +### 6.1 Primitive choice (confirmed) + +- **Generation:** Distributed Key Generation (Pedersen/GJKR-style; FROST has its own DKG, "DKG for FROST"/`trusted-dealer`-free variant). +- **Signing:** **FROST** (2-round) for Ed25519; **GG20/CGGMP** (multi-round) for secp256k1 ECDSA. +- **Build-vs-buy: reuse audited libraries. Do NOT roll your own threshold crypto.** + - FROST: a maintained `frost-ed25519` crate (ZF FROST family is the well-trodden choice). + - ECDSA: a maintained GG20/CGGMP implementation. + - ⚠️ **Open item:** the ECDSA-MPC library landscape varies in quality/maintenance and shifts over time. The specific crate must get a security review and a current-maintenance check at build time rather than being hard-committed from memory now. Selection criteria beyond maintenance: **identifiable aborts** (a misbehaving party can be pinpointed and ejected rather than silently stalling rounds) and **safe concurrent signing sessions** (multiple `message_hash` sessions in flight at once, per §5.3). + +### 6.2 Participants + +DKG parties = a mix of **IRL human participants** and **one or more Nautilus TEE participants**, all as equal DKG parties. Each party finishes holding a *verified share*; no party ever holds the whole key, and there is no central aggregation step. After DKG: +- Each TEE party's share is immediately **Seal-encrypted to that node's identity** under the per-node policy of §6.5. **PCR-only policies are forbidden:** all signer enclaves run identical code and therefore have identical PCRs, so a PCR-gated share could be decrypted by *any* operator's legitimately attested enclave — one malicious operator could collect k ciphertexts and reconstruct, collapsing k-of-n to 1. +- Human-held shares (if any persist beyond bootstrapping) need their own custody story — **decide whether humans are bootstrap-only or permanent share-holders** (open item §9). + +Run the DKG **twice** — once for the secp256k1 group key, once for the Ed25519 group key. + +### 6.3 MPC transport (standard node approach) + +- **libp2p P2P mesh** between enclaves over **mutually-attested, authenticated channels**: each node verifies peers' Nautilus attestation + on-chain-registered pubkey before accepting round messages. +- An **untrusted coordinator/relayer for liveness only**: queues/forwards round messages; cannot forge them (every round message is signed by a share-holder's enclave key). Can stall, never corrupt. +- At **N=1 launch** there is no mesh (single party); the transport turns on when N grows — no contract changes required. + +### 6.4 Lifecycle: restart, recovery, rotation + +- **Restart:** enclave loses in-memory shares → re-run the Seal 2-step key-load → re-decrypt its share from Seal → resume. No new DKG. Persistence is at the *share* level, which preserves the no-reconstruction property. +- **Node recovery / replacement:** provision a fresh enclave with identical PCRs; the node's **operator re-registers** the new instance's attested ephemeral pubkey into that node's `Enclave` object (an explicit, on-chain-visible authorization via the operator's cap — anomalous re-registrations are alertable); it then reloads the same Seal-encrypted share via §6.5. Identical PCRs alone are deliberately **not** sufficient. +- **Membership rotation / proactive refresh:** changing the party set or refreshing shares requires a **re-share / re-run DKG** (group key can stay fixed via resharing, or rotate via fresh DKG + `registerGroupKey` on each Inbox). Supported by the rotation-friendly `group_pubkey_id` in the envelope. + +### 6.5 Seal share policy (per-node binding — normative) + +Verified against the Nautilus `seal_policy.move` example and the Seal design docs. The stock example policy checks (a) a fixed identity `vector[0]`, (b) tx sender == wallet pk, and (c) an Ed25519 intent signature against `enclave.pk()` — where `enclave` is **whichever registered `Enclave` object the caller passes in**. It therefore authorizes *any* attested instance under the config: correct for one app-wide secret, unsafe for per-node key shares. + +Our policy, per share: + +- **Identity** = `[node_enclave_object_id]` — one identity per node, not a shared `0x00`. +- `seal_approve(id, signature, wallet_pk, timestamp, enclave: &Enclave, ctx)` keeps the example's three checks **and additionally asserts `object::id(enclave) == id`**, binding the share to exactly one node's registry entry. +- The node's `Enclave` object is updateable only via that **operator's cap**, so the cap is the per-node credential. Its custody (hardware wallet vs per-operator multisig) is an open item (§9). +- The **policy package must be immutable** (or upgrade-governed). Seal docs: "if a package is upgradeable, the access control policy can be changed at any time by the package owner." +- The **key-server set is frozen per ciphertext** ("The set of key servers is not dynamic once the data is encrypted"): rotating Seal servers means re-encrypting each share to the new set — cheap, since shares are tiny; this is Seal's own envelope-encryption recommendation applied to shares. + +Result, by construction: one malicious operator gets exactly **one** share. The threshold is defeated only by k colluding operators — or by whoever controls ≥ k operator caps, which is why cap custody and genuine operator independence matter more than any of this Move code. + +### 6.6 Seal key-server trust layer + +Seal privacy is t-of-n over the chosen key servers: a colluding quorum of t can derive the key for any identity in our namespace — i.e. decrypt **every node's share ciphertext**. This layer sits *above* the bridge's k-of-n and is stated in the §1 table. + +- **v1 testnet:** Mysten's open testnet key servers. Acceptable for testnet only. +- **Mainnet:** vetted independent operators at t ≥ 2 (Seal security best practices: "treat key server selection as a trust decision"; establish availability agreements), or a committee-mode (MPC) key server. Decide before mainnet (§9). + +--- + +## 7. Chain registry (generic seam, two chains implemented) + +A small on-chain + enclave-side registry, designed generic, populated with two entries for v1: + +``` +ChainRegistry[internal_id] = { + native_identifier: bytes // EVM chainId (HyperEVM) or Sui chain identifier + family: enum // EVM | SUI (selects sig scheme + adapter) + outbox_addr: bytes32 + inbox_addr: bytes32 + finality: { kind, depth_or_checkpoint_rule } +} +``` + +This is where "keep the abstraction generic, implement only HyperEVM+Sui" lives: the registry, the `family` enum, and the per-family encode/verify adapters are the only places a third chain would later plug in. + +--- + +## 8. Milestones (testnet-first, single enclave) + +**M0 — Repos & skeletons.** Fork `nautilus`; stand up Move packages (`enclave`, messaging, locker) and Solidity packages (messaging, locker) as interface stubs. Chain registry with HyperEVM-testnet + Sui-testnet entries. + +**M1 — Layer 1 messaging, 1-of-1.** Outbox/Inbox on both chains — the Sui Inbox uses the hot-potato receipt pattern from day one (§2.5), and the digest includes `DOMAIN_SEP` from day one (§2.2). Single enclave signs (no MPC yet): GG20 path stubbed to a single-party ECDSA, FROST path stubbed to single-party Ed25519. In-enclave TLS with the dual-provider commitment check (§5.4). Seal key-load working end-to-end (the Nautilus-Seal 2-step) on Mysten open testnet servers, using the per-node policy (§6.5) even at N=1. Permissionless relayer script. **Exit:** a signed generic message delivers end-to-end both directions, self-verifying on-chain. + +**M2 — Locker app (lock-and-mint).** Per-asset Locker on both chains (escrow on home, wrapped Coin/ERC-20 on foreign). Hash dedup, peer registration, per-asset pause, rate-limit with overflow queue + permissionless `claim` (§3.5). **Exit:** round-trip a test asset HyperEVM→Sui→HyperEVM with supply invariant holding, including a rate-limited transfer that queues and later claims. + +**M3 — Real threshold crypto.** Integrate audited FROST + GG20 libraries. Stand up DKG ceremony tooling. Move to N≥3, k=2 on testnet. libp2p attested mesh + liveness coordinator. **Exit:** group key generated by DKG (never reconstructed), k-of-n signing live, one-node-down tolerated. + +**M4 — Lifecycle & hardening.** Restart/recovery from Seal (including the operator re-registration step, §6.4), membership rotation + `registerGroupKey`, guardian/governance wiring for all pause/threshold/peer setters, finality-parameter confirmation for HyperEVM. Security review of MPC library choice **and of our Nautilus fork** — the upstream template is explicitly unaudited ("for evaluation purposes only"). **Exit:** runbook-complete, audit-ready. + +--- + +## 9. Open items to resolve before/within build (not blocking the architecture) + +1. **HyperEVM finality semantics + dual-block architecture** — confirm against current Hyperliquid docs; set confirmation depth accordingly (§4). +2. **ECDSA-MPC library selection** — current-maintenance + security review at build time; prefer implementations with identifiable aborts and safe concurrent-session handling (§6.1). +3. **Human DKG participants: bootstrap-only or permanent share-holders?** Affects custody design for non-TEE shares (§6.2). +4. **Operator-cap custody topology** — hardware wallet vs per-operator multisig for the `Enclave`-object registration caps (§6.5). +5. **Mainnet Seal key-server set** — vetted independent t-of-n or committee mode; testnet uses Mysten open servers (§6.6). +6. **Relayer economics** — permissionless relayers pay destination gas with no fee mechanism specified. v1 recommendation: self-relay (our frontend/relayer eats the gas); decide whether a fee mechanism is ever needed. +7. **Wrapped-asset metadata/decimals normalization** across HyperEVM ERC-20 ↔ Sui Coin (NTT-style trimmed-amount handling) — flag for the Locker decode path (§3). +8. **Governance/guardian key** — who holds pause + registry authority; multisig topology. + +Resolved since v0.1: nonce policy (no ordering — dedup only, §2.6); rate-limit default (ON, overflow queues, §3.5); Seal policy shape (per-node binding, §6.5); signing API shape (async, request-triggered, §5.3). + +--- + +## 10. What this design deliberately does NOT do + +- No Wormhole / LayerZero dependency (removed by design; our Seal+Nautilus signers *are* the transport). +- No key reconstruction at signing time. +- No trust in relayers (messages self-verify on-chain). +- No optimistic delivery in v1 (finality-gated only). +- No single-key-in-Seal (shares only). +- No PCR-only Seal policies — every share is bound to one node's registered on-chain identity (§6.5). +- No ordering guarantees at Layer 1 (dedup-only delivery; apps needing order sequence it themselves). +- No dynamic-dispatch assumptions on Sui (hot-potato receipt, not callbacks, §2.5). +- No revert-on-rate-limit (overflow queues with delayed claim, §3.5). diff --git a/bridge_tickets/01-domain-separator.md b/bridge_tickets/01-domain-separator.md new file mode 100644 index 00000000..7a058811 --- /dev/null +++ b/bridge_tickets/01-domain-separator.md @@ -0,0 +1,56 @@ +# 01 — DOMAIN_SEP in the message digest (+ redeploy) + +**Status (2026-07-01): DONE — both chains redeployed live, parity verified.** +- Steps 1–4, 7: code complete, all tests green (Rust 16 + 3, Move 14, Solidity 25). +- Three-way digest parity locked: Rust `known_digest_vector`, Move + `message_tests::known_digest_vector`, Solidity `test_known_digest_matches_sui` + all assert `0x535392…d707` (test salt `0x01*32`). +- Signature parity: the Rust-generated domain-separated Ed25519 signature verifies + on-chain in Move `receive_accepts_valid_threshold_signature`. +- Step 5 (Sui): fresh package `0x6435311f…`, Inbox/Outbox created with salt, + chains + Ed25519 group key wired. +- Step 5 (EVM): redeployed to HyperEVM testnet via the Chainlink RPC (canonical + host blocked by an upstream SNI egress filter). Registry/Inbox/Outbox live. +- Step 6 (smoke): **cross-chain parity confirmed live** — `domainSep()` on both + EVM contracts and `domain_sep` on both Sui objects all read `0x734dcc…d1dc`. +- All addresses in `DEPLOYMENTS.md`. +- Open follow-up → ticket 02: Sui ChainRegistry's HyperEVM entry has zero EVM + addrs (registered before EVM deploy); needs an `update_chain` govt fn in + registry.move to backfill the real EVM Outbox/Inbox. +- Regenerate vectors after any digest change: `cargo run -p bridge-signer --example group_keys`. + + +**Spec:** bridge-spec.md §2.2 (mandatory domain separation) +**Why now:** the deployed digest is a bare `keccak256(encode(message))` on all three implementations. Every testnet redeploy (fresh `consumed` set, same registry IDs) lets previously signed messages replay. Changing the digest later invalidates all accumulated signatures and touches every layer — do it before more traffic and before the other tickets land on the wrong format. + +## Design decisions (locked) + +- **Derivation:** `DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deployment_salt)`, `message_hash = keccak256(DOMAIN_SEP || encode(message))`. Contracts take the 32-byte `deployment_salt` at construction and derive `DOMAIN_SEP` once on-chain (auditable, no mis-derived constants). Services take the salt in config and derive identically. +- **Storage:** one salt per logical deployment, shared by both chains and both services. + - Solidity: `bytes32 immutable domainSep` on `Outbox` and `Inbox` (constructor arg). + - Move: `domain_sep: vector` field on `Outbox` and `Inbox`, set in the governance-gated `create(...)`. + - Rust: `deployment_salt` in signer-service and relayer configs. + - A cross-chain mismatch self-surfaces (signatures don't verify) — no extra consistency machinery. +- **Salt for this testnet deployment:** `keccak256("sui-options-bridge:testnet:2026-07")`, recorded in DEPLOYMENTS.md. Test vectors use a fixed dummy salt (`[0x01; 32]`). +- **Rust API:** change `CrossChainMessage::digest()` to `digest(domain_sep)` — deliberately breaking so no saltless call sites survive. + +## Steps + +1. **Rust `bridge-types` → generate the new parity vector.** Add `derive_domain_sep(salt)`; thread `domain_sep` through `digest()`. Update `known_digest_vector` with the test salt; the captured digest becomes the vector Move + Solidity must reproduce. + *Verify:* `cargo test -p bridge-types`. +2. **Move package.** `message::hash(m, domain_sep)`; `outbox::create` / `inbox::create` take `deployment_salt`, store the derived sep; `send`/`receive` use it. Fix the stale "windowed ordering" doc comment in `inbox.move` (behavior is dedup-only). Update the Move parity test. + *Verify:* `sui move test`. +3. **Solidity package.** `Message.hash(m, domainSep)`; immutables + constructor args on `Outbox`/`Inbox`; `Deploy.s.sol` reads the salt from env. Fix the same stale comment in `Inbox.sol`. Update the parity test. + *Verify:* `forge test`. +4. **Services.** `bridge-signer::sign` holds the sep; signer-service + relayer configs gain `deployment_salt`; the relayer's event decoder hash-check uses it. Update both `config.example.toml`. + *Verify:* `cargo test -p bridge-signer -p bridge-signer-service -p bridge-relayer`. +5. **Republish + rewire.** Changing `public fun hash`'s signature violates Sui upgrade compatibility → **fresh publish** of `sui_bridge`; rebuild `sui-locker` against the new dep (no code change — it never hashes). Fresh EVM deploy with the salt. Re-register both chains + the **existing** group keys (no rotation needed — the new domain already invalidates old signatures). Update DEPLOYMENTS.md with new IDs + salt. + *Verify:* deploy output matches DEPLOYMENTS.md; live signer `/group_keys` matches what's registered. +6. **End-to-end smoke** (mirror the 2026-06-29 procedure). `Outbox.send` on new Sui Outbox → relayer reconstructs + hash-checks → signer envelope → submit to HyperEVM Inbox (anvil fork first, then live). Negative test: a signature over the old unsalted digest must be rejected. + *Verify:* delivered + consumed on HyperEVM testnet; old-digest replay reverts. +7. **Spec bookkeeping.** Mark §2.2 implemented; adopt the packed-layout wording; note in §9-resolved. + +## Out of scope +RPC verifier (02), EVM Locker (03), queue (05), async API (06). + +**Depends on:** nothing. **Blocks:** all other contract-touching tickets (build on the final digest). diff --git a/bridge_tickets/02-rpc-source-verifier.md b/bridge_tickets/02-rpc-source-verifier.md new file mode 100644 index 00000000..fb4d95eb --- /dev/null +++ b/bridge_tickets/02-rpc-source-verifier.md @@ -0,0 +1,61 @@ +# 02 — RPC source verifier (§5.4 security boundary) + +**Status (2026-07-01): DONE — code complete, all tests green.** +- `RpcVerifier` behind the existing `SourceVerifier` trait, over a mockable + `CommitmentProbe` abstraction; quorum = **every configured provider must + independently confirm** (fail closed on any Pending/NotFound/error/disagreement). +- `EvmProbe` (eth_getLogs on the Outbox `MessageCommitted` indexed topic + + eth_blockNumber finality) and `SuiProbe` (suix_queryEvents match on + `message_hash`; queryable ⇒ final). Pure parsers factored out and unit-tested. +- Config: `[[source_chains]]` mirror (family / rpc_urls / outbox_addr|package_id / + confirmations / allow_single_provider) + `environment`; `trust_all` now a hard + error outside `environment = "dev"`; unknown route ⇒ 422. +- `update_chain` governance fn added to `registry.move` (+ 2 Move tests) to backfill + peer addresses — the ticket-01 follow-up. NOTE: using it on the *current* live Sui + registry needs a redeploy (new package types); the live verifier reads the Outbox + addr from its own config, so this isn't blocking. +- **Tests:** signer-service 15 unit (6 verifier quorum via mock probe, 6 probe + parsers, 1 topic0 guard, 2 build-gating) + 2 sign integration; Move 16/16. + **Anvil integration** (`examples/verify_evm_smoke.rs`): real Outbox + real + `MessageCommitted` drove the EvmProbe through NotFound → Final(0-conf) → + Pending(100-conf) → Final(after mining 100) — the §5.4 "refuse before finality, + sign after" path proven on a live node. +- Digest-parity transitivity: `verify_committed` recomputes `digest(msg, domain_sep)` + which ticket 01 already locked byte-identical to the on-chain emitted hash, so the + probe finds the right log by construction. +- Deferred: live Sui suix_queryEvents run (sandbox reqwest→fullnode caveat); parsing + is unit-tested against sample payloads. + +--- + + +**Spec:** bridge-spec.md §5.4, §4 (finality) +**Why:** the only verifier today is `TrustAllVerifier` (`bridge-signer-service/src/verifier.rs`) — the signer signs any well-formed message, so anyone who can reach it can mint arbitrarily on the deployed contracts. This is the single largest gap between the deployed system and the spec's security claims. `verifier.rs` already defines the `SourceVerifier` trait and bails on `mode = "rpc"`; this ticket implements it. + +## Scope + +Implement `RpcVerifier` behind the existing trait: given a `CrossChainMessage`, confirm the **registered** source Outbox committed this **exact** message (recompute the digest, match the on-chain committed hash) at **source finality**, else refuse to sign. + +### Per-family checks +- **Sui source:** query `MessageCommitted` events from the registered `sui_bridge` package (`suix_queryEvents` by `MoveEventModule`, as the relayer already does), match the full field set + digest. Sui events from a fullnode are from finalized checkpoints — no extra depth gate (§4). +- **EVM source:** `eth_getLogs` on the registered Outbox address for `MessageCommitted` with the digest as the indexed topic; recompute the hash from event fields and compare. Enforce `confirmations >= finality_value` from config (currently 12 for HyperEVM — see ticket 10 for confirming that number). + +### Provider quorum +§5.4 requires ≥2 independent RPC providers per source chain before signing. Config takes a list per chain; the verifier requires agreement from **all configured providers** (start with 2). Single-provider config is allowed only with an explicit `allow_single_provider = true` + startup warning, for dev. + +### Configuration +Signer-service config gains a small chain-registry mirror: per internal chain id → `{family, rpc_urls[], outbox_addr/package, finality}`. Reject any message whose `src_chain_id` isn't configured (unregistered route). + +### Guardrails +- `trust_all` stays available but the startup warning becomes a hard refusal unless `environment = "dev"`. +- Verification failures return the existing 422/503 mapping (`handlers.rs` already distinguishes `NotCommitted` vs `Unavailable`). + +## Verify (exit criteria) +- Unit: mocked providers — signs on quorum-confirmed commitment; refuses on (a) unknown route, (b) hash mismatch, (c) missing event, (d) insufficient confirmations, (e) provider disagreement. +- Integration: anvil — commit a message, request a signature before N confirmations (refused), mine to depth (signed). Sui testnet — sign only after the real `MessageCommitted` is queryable. +- Live: deployed signer runs `mode = "rpc"`; a hand-crafted uncommitted message is refused (422). + +## Out of scope +TLS-in-enclave and pinning (ticket 07 — the provider-quorum interface built here is what moves inside the enclave); async API (06). + +**Depends on:** 01 (digest recomputation must use DOMAIN_SEP). **Blocks:** meaningful security of everything; 07 builds on it. diff --git a/bridge_tickets/03-evm-locker.md b/bridge_tickets/03-evm-locker.md new file mode 100644 index 00000000..8327d7ed --- /dev/null +++ b/bridge_tickets/03-evm-locker.md @@ -0,0 +1,49 @@ +# 03 — EVM Locker (lock-and-mint app, HyperEVM side) + +**Status (2026-07-01): DONE — code complete, all tests green, deploy validated on anvil.** +- `Locker.sol` (escrow/mint modes) + `WrappedToken.sol` (minimal owner-mint/burn ERC-20). +- Outbound `lock`/`burn` (mode-checked) → NTT decimals scaling with dust rejection → + `TransferPayload.encode` (shared 72-byte wire format) → `Outbox.send`. +- Inbound `onReceive` (only-Inbox, peer + asset checks) → scale from wire → release/mint. +- **Rate-limit overflow queue built in from day one** (§3.5): over-cap inbound transfers + enqueue `{recipient, wireAmount, unlockAt}` and NEVER revert; permissionless `claim` + after the window; double-claim guarded. (The Sui side still reverts — ticket 05.) +- `transferAdmin` for governance handoff; `DeployLocker.s.sol` (both modes, wraps + + ownership transfer + peer wiring), refactored to dodge script stack-too-deep. +- **Tests — 16 forge Locker tests, all green:** escrow-in/scale, dust reject, wrong-mode, + unknown-peer, burn-scale (6-dec), release-escrow (18-dec), mint-foreign, only-Inbox, + peer/asset mismatch, rate-limit queue+claim (+StillLocked +double-claim), pause in/out, + admin-gating, transferAdmin handoff, **supply-invariant round trip**, and + **end-to-end through the real Inbox** (ECDSA verify → dispatch → mint). Full suite 41/41; + Sui-locker 10/10 unaffected. +- **Anvil deploy validated:** `DeployLocker` (Mint) deployed WrappedToken + Locker, handed + ownership to the Locker, wired the peer — verified via `cast`. +- Deferred to ticket 04: live testnet Locker deploy + the HyperEVM↔Sui round trip (needs a + live Sui Locker instance + the EVM→Sui relayer; that's where the round-trip exit lives). + +--- + + +**Spec:** bridge-spec.md §3 +**Why:** the Sui Locker exists (`sui-bridge-contracts/sui-locker/`); the EVM side has only the `TransferPayload` library. Without it there is no home-chain escrow and no M2 round trip. + +## Scope + +`Locker.sol` — one deployment per asset, mirroring the Sui Locker's semantics: + +- **Modes:** home = escrow vault (ERC-20 `safeTransferFrom` in, transfer out); foreign = wrapped ERC-20 with mint/burn rights held by the Locker. Include a minimal `WrappedToken.sol` (ERC-20, owner-mint/burn) for the foreign case. +- **Outbound** `lock(amount, dstChainId, recipient32)` (home) / `burn(...)` (foreign): escrow-or-burn, encode via the existing `TransferPayload` library (same wire format as `locker::transfer_payload` on Sui, including wire-decimals scaling with dust rejection — mirror `to_wire`/`from_wire` from `locker.move`), then `Outbox.send(dstChainId, peer, payload)`. `src_app` = the Locker's address (Outbox already records `msg.sender`). +- **Inbound** `onReceive(srcChainId, srcApp, payload)`: `require msg.sender == inbox`; `require srcApp == peers[srcChainId]`; decode payload; assert asset id; scale from wire decimals; release (home) or mint (foreign). +- **Rate limit with overflow queue (§3.5, build it right the first time — greenfield):** windowed cap in wire units; over-limit transfers are enqueued `{recipient, amount, unlockAt}` and **never revert**; permissionless `claim(queuedId)` after the window. Emit events for enqueue/claim. +- **Admin:** `setPeer(chainId, addr32)`, `setPaused(bool)`, `setRateLimit(window, cap)` — owner/guardian per the Registry's existing role pattern. + +## Wiring +- Deploy script additions: locker deploy + peer registration both directions (EVM Locker ↔ Sui Locker object id). +- Pick/mint a test asset on HyperEVM testnet (home) and publish the matching wrapped coin package + `create_mint_locker` on Sui (the Sui side already supports this — onboarding = package publish, per §3.1). + +## Verify (exit criteria) +- Forge tests: escrow/mint/burn/release paths; only-Inbox and peer checks; decimals scaling parity vectors against the Sui `transfer_payload` tests (shared test vectors, same discipline as the message-digest vector); rate-limit enqueue + claim; pause. +- Supply invariant test: wrapped minted on foreign ≤ escrowed on home across a scripted sequence. +- Testnet: deploy + peer-wire against the live Sui Locker. + +**Depends on:** 01 (deploys against the new digest contracts). **Blocks:** 04 (round trip needs both lockers). diff --git a/bridge_tickets/04-relayer-evm-to-sui.md b/bridge_tickets/04-relayer-evm-to-sui.md new file mode 100644 index 00000000..537fd346 --- /dev/null +++ b/bridge_tickets/04-relayer-evm-to-sui.md @@ -0,0 +1,67 @@ +# 04 — Relayer: EVM→Sui direction (EVM watcher + generic Sui submitter) + +**Status (2026-07-01): code complete + tested; live round trip deferred.** +- **L1 BCS layer:** `message::from_bcs` / `envelope::from_bcs` (Move) + `to_move_bcs` + (Rust) so the relayer passes plain `vector` args. `bridge_receive` now takes + BCS bytes and decodes internally (dispatch-design §3.1). **Parity proven E2E in + Move:** `receive_accepts_bcs_decoded_relayer_args` runs Rust-produced bytes through + `from_bcs` → real `inbox::receive` with the domain-separated Ed25519 sig verifying. +- **`EvmSourceWatcher`** (alloy): eth_getLogs on the Outbox `MessageCommitted` topic, + reconstruct + hash-check, confirmation-depth gate. **Anvil integration passed:** + 2 real events reconstructed at 0-conf, 0 at 100-conf, 2 after mining 100. +- **`SuiDestSubmitter`** (sui-tx): reads `dst_app`'s on-chain type → `parse_dispatch` + derives `(package, module, type_args)` → single `bridge_receive` MoveCall with the + L1 shared objects + BCS byte args → `submit_ptb`. Type-derivation is unit-tested + (generic/nested/non-generic/malformed); the PTB path compiles against the real Sui + SDK. `is_delivered` returns false (on-chain `consumed` set is the real guard; a + devInspect pre-skip is a noted follow-up). +- **Family `Router`** routes each message by `chain_id::family(dst)` → EVM/Sui submitter + (unit-tested); `main.rs` runs both source watchers concurrently over one router. +- **Ops:** relay-submission failures log `alert_id = "tx-failed-bridge-relay"` + (.claude/tx-alerting.md); benign already-delivered races surface as AlreadyDelivered. +- **Tests:** relayer 9 unit + EvmSourceWatcher anvil integration; bridge-types BCS + parity; Move 18 (incl. 2 BCS parity); locker 10; all suites green. +- **✅ Live HyperEVM→Sui→HyperEVM round trip DONE (M2 exit, 2026-07-01):** deployed the + Sui Locker`` (Mint) + EVM Locker (Escrow) + test token, wired peers both ways, and + ran the full round trip on testnet — lock 1 tBTC → mint 1 WBTC → burn → release 1 tBTC, + supply invariant holding, both signatures verified on the real Inboxes, both reconstructed + digests matching the on-chain `messageHash`. Addresses in `DEPLOYMENTS.md`. The submitting + relay was CLI/cast-driven this round; the relayer *binary* is anvil/unit-verified. NOTE: + the Sui fullnode egress is NOT blocked (re-tested — curl/reqwest/sui-sdk all reach it in + ~0.2–0.3s); the earlier "reqwest hangs" note in `sui_source.rs` doesn't reproduce, so the + binary can drive this autonomously — only its write path hasn't been exercised yet. +- Out of scope (unchanged): descriptor registry + custom adapter (§3.3/§3.5). + +--- + + +**Spec:** bridge-spec.md §2.5 (Sui delivery), sui-bridge-contracts/relayer-dispatch-design.md §3 +**Why:** only Sui→EVM relays today. There is no EVM source watcher and no Sui destination submitter — the Sui side is blocked by design on the `bridge_receive` convention (see the dispatch-design doc), which the Sui Locker now implements. This ticket completes the M2 round trip. + +## Scope + +### 1. `EvmSourceWatcher` +Poll `MessageCommitted` on the registered EVM Outbox via `eth_getLogs` (block-range cursor, confirmation depth from config so the relayer doesn't hand the signer messages it will refuse). Reconstruct `CrossChainMessage` from event fields and hash-check against the committed hash — same discipline as `sui_source.rs`. + +### 2. `SuiDestSubmitter` (dispatch-design §3.2/§3.4 — type-derived, zero per-app config) +1. `is_delivered`: dev-inspect `inbox::is_consumed(digest)`. +2. Resolve dispatch target from the message itself: `getObject(dst_app)` → object type `0xPKG::locker::Locker` → `(package, module, type args)`; assume the standard entry `bridge_receive`. +3. Resolve object args: Inbox + GroupKeyRegistry ids from config, `dst_app`, `Clock 0x6`; fetch `initial_shared_version` + mutability via RPC. +4. Build the PTB. Message/envelope construction: add `message::from_bcs` / `envelope::from_bcs` Move helpers (the dispatch-design's L1 addition) + `bridge_types` BCS emitters, so the PTB passes two `vector` pure args — simpler than chaining `message::new`/`envelope::new` MoveCalls. (L1 package change → coordinate with ticket 01's republish if possible.) +5. Sign with the relayer's Sui key, submit, confirm effects. + +### 3. Family router +Replace the single-submitter wiring in `main.rs` with routing by `chain_id::family(message.dst_chain_id)` → `EvmDestSubmitter` / `SuiDestSubmitter`. Both watchers run concurrently; the `SourceWatcher`/`DestSubmitter` traits already support this shape. + +### 4. Ops conventions (repo standard) +Relayer submits transactions now in both directions: every tx-submission failure at the relay handler must `error!(alert_id = "tx-failed-bridge-relayer-...")` per .claude/tx-alerting.md, with benign race-losses (already-consumed on arrival) suppressed as info. + +## Verify (exit criteria) +- Unit: EVM event decode + hash-check vectors; dispatch-target derivation from a mocked `getObject`; router selection. +- Integration: localnet/anvil pair — EVM `lock` → watcher → signer → `bridge_receive` PTB lands, wrapped coin minted. +- Live (M2 exit): **round-trip a test asset HyperEVM→Sui→HyperEVM with the supply invariant holding**, on the deployed testnet contracts, driven end-to-end by one relayer process. + +## Out of scope +Descriptor registry + custom-adapter escape hatch for non-standard apps (dispatch-design §3.3/§3.5) — add when a second app family exists. + +**Depends on:** 01 (digest), 02 (signer must verify EVM commitments before signing them), 03 (EVM Locker to originate/receive). **Blocks:** M2 completion. diff --git a/bridge_tickets/05-sui-rate-limit-queue.md b/bridge_tickets/05-sui-rate-limit-queue.md new file mode 100644 index 00000000..303b3acf --- /dev/null +++ b/bridge_tickets/05-sui-rate-limit-queue.md @@ -0,0 +1,44 @@ +# 05 — Sui Locker: rate-limit overflow queue (retrofit) + +**Status (2026-07-01): DONE — code complete, all tests green.** +- `enforce_rate_limit` (which aborted with code 7) replaced by `within_rate_limit` + returning a bool; `apply_inbound` now delivers when within cap, else enqueues a + `QueuedTransfer { recipient, wire_amount, unlock_at_ms }` in a `Table` and + emits `TransferQueued` — **never reverts**; the message is still consumed at the Inbox. +- `claim(locker, id, clock, ctx)` — permissionless after `unlock_at_ms`, releases/mints + the exact amount, emits `TransferClaimed`; pause-gated; claims don't consume budget. + `unlock_at_ms` = `window_start_ms + rate_limit_window_ms` (window end at enqueue). +- Shared `deliver` helper (escrow take / mint) used by both the immediate and claim paths. +- Views `is_queued` / `queued_transfer`; error 7 retired, added `EStillLocked=11`, + `EUnknownQueueEntry=12`. Behavior now matches the EVM Locker's queue semantics. +- **Tests — 13/13 Sui locker:** queue-then-claim happy path (verifies unlock time, that + only the in-cap transfer delivered, and the claim releases the queued one), + claim-before-unlock (11), unknown-entry (12), paused-claim (3), plus the prior 9. +- Note (as flagged): adding struct fields is upgrade-incompatible → the live Sui Locker + needs a **fresh publish** of this package. **Done 2026-07-01** — this queue-enabled locker + package (`0x3ef9…`) is the live Locker instance used in ticket 04's round trip (see + `DEPLOYMENTS.md`). + +--- + + +**Spec:** bridge-spec.md §3.5 — "Rate-limit overflow queues; it never reverts." +**Why:** `locker.move` `enforce_rate_limit` aborts with `ERateLimitExceeded` (locker.move:284). An over-limit inbound transfer therefore strands the user's funds at source until the window resets, and relayers burn gas on retries. The EVM Locker (ticket 03) ships the queue from day one; this retrofits the Sui side to match. + +## Scope + +- Replace the abort path in `apply_inbound`: when `window_used + amount > cap`, record `QueuedTransfer { recipient, wire_amount, unlock_at_ms }` (Table keyed by a counter, or dynamic-field objects) and emit `TransferQueued`. Delivery always succeeds; only the payout is delayed. The message is still consumed at the Inbox — that's the point. +- `claim(locker, queued_id, clock, ctx)` — **permissionless** after `unlock_at_ms`; releases escrow / mints to the recorded recipient; emits `TransferClaimed`. Claims do not consume rate-limit budget (the delay itself was the control), matching NTT semantics. +- `unlock_at_ms` = window end at enqueue time (`window_start_ms + rate_limit_window_ms`). +- Views: `queued(locker, id)`, count. Admin: none new — pause already blocks `claim` via the existing `paused` check (add the assert to `claim`). +- Update the module doc + spec cross-refs; keep `ERateLimitExceeded` error code removed or repurposed deliberately (breaking change to error surface is fine pre-audit). + +## Compatibility note +Adding fields to `Locker` / new structs is **not** upgrade-compatible for existing struct layouts — expect a fresh locker publish. Sequence this with ticket 01's republish (one coordinated redeploy) if both are pending; otherwise plan a second locker publish + peer re-wiring. + +## Verify (exit criteria) +- Move tests: under-limit passes; over-limit enqueues (does NOT abort) and delivery still marks the message consumed; `claim` before unlock aborts; after unlock releases the exact amount; multiple queued entries; paused locker blocks claim. +- M2 exit test (spec §8): scripted round trip including "a rate-limited transfer that queues and later claims." +- Parity: semantics match the EVM Locker queue (shared scenario vectors). + +**Depends on:** 03 (parity target; or land independently if 03 slips). **Blocks:** M2 exit criteria. diff --git a/bridge_tickets/06-async-signing-api.md b/bridge_tickets/06-async-signing-api.md new file mode 100644 index 00000000..d8934a3d --- /dev/null +++ b/bridge_tickets/06-async-signing-api.md @@ -0,0 +1,51 @@ +# 06 — Async signing API (submit → poll by message_hash) + +**Status (2026-07-01): DONE — code complete, tested (unit + live HTTP).** +- `POST /sign_requests` → `202 {message_hash, status, envelope?}`, idempotent per hash + (duplicate coalesces onto the existing session via a Pending marker); + `GET /sign_requests/:hash` → `{status: pending|signed, envelope?}` or 404. `/sign_message` + removed. (Route uses axum-0.7 `:param` syntax.) +- Session store (`sessions.rs`): keyed by hash, TTL-evicts terminal sessions, bounded map + (sheds load → 503), verify-failure abandons (not cached → retryable). Unit-tested. +- DoS guardrails (§5.3): **verify-before-admit** (uncommitted → 422 at the door, never + queued), in-flight dedup, bounded map, per-IP fixed-window rate limit (`ratelimit.rs`, + wired via `ConnectInfo`). Config knobs added (ttl/cap/rate). +- Relayer `signer_client.rs`: submit-then-poll behind the unchanged `RemoteSigner::sign`, + so `relay.rs` is untouched and the M3 MPC turn-on needs no client edit. +- **Tests:** signer-service 22 lib (5 session + 2 ratelimit + verifier/probe) + 5 integration + (completes+pollable, duplicate-coalesces, uncommitted-422-at-door, unsupported-family-400, + unknown-hash-404); relayer 9. **Live HTTP smoke:** ran the service, POST→202 signed with + the exact known-vector signature, GET→200 same, unknown→404. + +--- + + +**Spec:** bridge-spec.md §5.3 +**Why:** the signer exposes a synchronous `POST /sign_message` (router.rs:23). FROST/GG20 at k > 1 are multi-round protocols across nodes — a synchronous request/response API cannot survive M3 (ticket 09). The spec resolved to design the poll model now so the interface doesn't break when MPC turns on. Also hardens the public DoS surface. + +## Scope + +### Endpoints (replace `/sign_message`) +- `POST /sign_requests` `{message}` → `202 {message_hash, status}`. Idempotent **per message_hash**: one signing session per digest, ever; duplicate submissions coalesce onto the existing session. +- `GET /sign_requests/{message_hash}` → `{status: "pending" | "signed" | "rejected", envelope?, reason?}`. +- Keep `/group_keys`, `/get_attestation`, `/health` unchanged. + +### Session store +In-memory map `message_hash → SessionState` with TTL eviction for terminal states. At M1 the "session" is trivial (verify → sign inline, likely completing before the first poll) — the point is the **interface**, which M3 swaps internals under. + +### DoS guardrails (§5.3) +- Run the ticket-02 source-commitment verification **before** admitting a session — anything not committed on a registered Outbox is rejected at the door (422), never queued. +- In-flight dedupe by hash (free with idempotency); bounded session map; per-source-IP rate limit on `POST`. + +### Relayer update +`signer_client.rs` (`RemoteSigner` trait impl): submit, then poll with backoff until `signed`/`rejected`. The trait signature can stay `async fn sign(&self, m) -> Result` — polling is an implementation detail, so `relay.rs` is untouched. + +### Migration +Remove `/sign_message` in the same change (grep-able, only the relayer consumes it). Update both READMEs + config examples + `tests/sign.rs`. + +## Verify (exit criteria) +- Unit: idempotency (two concurrent POSTs of the same message → one session, both get the same envelope); rejected-at-door for uncommitted messages; TTL eviction; poll state machine. +- Integration: relayer end-to-end through the new API on the live smoke path. +- Load sanity: N duplicate submissions cause exactly one verify + one sign. + +**Depends on:** 02 (verify-before-queue is the admission gate). **Blocks:** 09 (MPC needs the async surface). diff --git a/bridge_tickets/07-nautilus-enclave.md b/bridge_tickets/07-nautilus-enclave.md new file mode 100644 index 00000000..43e6c7c5 --- /dev/null +++ b/bridge_tickets/07-nautilus-enclave.md @@ -0,0 +1,112 @@ +# 07 — Nautilus enclave: run the signer inside AWS Nitro + +**Spec:** bridge-spec.md §5.1–§5.4 · **Milestone:** M3 entry · **Status:** IN PROGRESS +**Landed:** Phase 2 (arm64 CI build/deploy workflow + PCR0 drift gate) and Phase 3 (on-chain enclave registry — `sui-bridge-contracts/enclave/`, 7 Move tests). **Remaining (needs Nitro hardware):** Phase 1 (nautilus port + in-enclave TLS egress), Phase 4 (in-enclave chain view), Phases 5–6 (terraform + lifecycle). +**Why:** today `bridge-signer-service` is a plain process with curve seeds in config. The spec's trust model requires it to run inside an attested AWS Nitro enclave so that (a) only approved code, registered on-chain, can produce signatures, and (b) the enclave's *chain view* — the §5.4 "was this committed at finality" check — can't be forged by the untrusted host. This ticket is the enclave migration only; Seal-based key provisioning is ticket 08 (this one can boot with a config-injected seed for staging so the two are independently testable). + +**Hard prerequisite:** a Nitro-Enclave-capable EC2 instance with `nitro-cli`. The vCPU floor is processor-dependent — **Intel/AMD need ≥4 vCPU** (whole hyperthread pairs are dedicated to the enclave and ≥2 vCPU must remain for the parent → smallest is `*.xlarge`), but **Graviton needs only ≥2 vCPU** (no SMT → 1 parent + 1 enclave → smallest is `*.large`). Bare-metal, T-family burstable (t3/t4g), and single-core instances are excluded regardless. Our signer workload is light (axum + rustls + the §5.4 verifier), so **default to a `c7g.large` (2 vCPU / 4 GB Graviton)** — ample and cheaper than an Intel xlarge. Caveats: the EIF must be built for **aarch64** and **PCRs are arch-specific** (don't mix arches across the signer set); revisit sizing only if in-enclave crypto (ticket 09) turns CPU-heavy. None of this is doable in the dev sandbox. + +--- + +## Background you need before starting + +- A Nitro enclave is a stripped VM with **no network, no persistent storage, no interactive access**. Its only I/O is a **vsock** channel to the parent EC2 instance. Everything the enclave reaches (RPC, Seal servers) is forwarded by the parent over vsock — which is why the parent is untrusted and TLS must terminate *inside* the enclave. +- **Attestation:** the Nitro Security Module (NSM) produces a COSE_Sign1/CBOR **attestation document** signed up to the AWS Nitro root CA, containing the enclave's measurements (**PCR0** = image, **PCR1** = kernel, **PCR2** = app) and a caller-supplied `public_key` field (we put the enclave's boot-fresh ephemeral pubkey there). +- `MystenLabs/nautilus` gives us: the reproducible EIF build template, `src/nautilus-server` (an axum app with `/get_attestation`), the vsock traffic-forwarder, `allowed_endpoints.yaml`, and `move/enclave/sources/enclave.move` which **verifies that attestation on-chain** and registers an `Enclave` object. + +## Detailed implementation plan + +### Phase 1 — Vendor nautilus (pinned) + embed the signer as a lib (no attestation yet) + +**Structure decision — vendor into THIS monorepo, NOT a separate fork repo.** The signer is already a workspace crate and all bridge work lives on one PR/audit surface; a separate `ewitulsk/nautilus` fork would split the signer from its enclave wrapper and force an awkward cross-repo build. So vendor the needed nautilus subtree (`src/nautilus-server` + the EIF build tooling + the vsock traffic-forwarder + `allowed_endpoints.yaml`) into `rust-backend/bridge-enclave/`, **pinned to a specific upstream commit**, recorded in a `FORK_DELTA.md` (upstream is Apache-2.0 and explicitly unaudited). This mirrors what we already did for the Move side (`sui-bridge-contracts/enclave/` vendored `enclave.move`). + +**Bring-up sequence (once the c7g.large is up):** +1. `git clone` upstream nautilus and run the **stock example** on the box first — validate the full pipeline (reproducible build → `build-enclave` → attestation doc → `register_enclave` against the `bridge_enclave` registry from Phase 3) *before* touching our code. Fast confidence that the hardware + framework path works end-to-end. +2. Only then vendor the pinned subtree in and swap the example app for our signer. +> Clone to bring up + validate; vendor-pinned to ship. The loose clone is never the committed artifact — PCRs require the exact build inputs to be version-controlled + auditable. + +**Embed the signer as a library — do NOT rewrite it into the app slot.** Library-fy `bridge-signer-service` (expose its router + `AppState` from a `lib` crate) and have the vendored nautilus-server app depend on it **by path**, rather than moving/duplicating the logic. The signer's HTTP surface (ticket 06: `/sign_requests`, `GET /sign_requests/:hash`, `/get_attestation`, `/health`) becomes the enclave's public surface; admin routes (Seal load, ticket 08) bind to the vsock-local admin port only. + +**Reproducibility — keep the enclave build a SEPARATE Cargo project.** nautilus-server pins its own dependency versions for deterministic PCRs, which can clash with the main workspace's versions. So `rust-backend/bridge-enclave/` carries its **own `Cargo.lock`** and pulls the signer lib as a path dep — it is **not** a member of the main workspace. (This is why the CI Dockerfile builds from the enclave dir with its own lock, and why the signer must be lib-shaped, not folded into the workspace binary.) + +**Egress rework (the real work):** the signer's `EvmProbe`/`SuiProbe` (ticket 02) currently make direct `reqwest` calls. Inside the enclave there is no direct socket — route all outbound HTTPS through the vsock proxy, with **rustls terminating inside the enclave** so the parent forwards ciphertext only. Concretely: a custom `reqwest` connector (or `hyper` client) whose transport is vsock→parent-forwarder→TCP, wrapping the stream in an in-enclave rustls `ClientConnection` pinned to the configured provider certs. `SuiClientBuilder` (SuiDestSubmitter) needs the same treatment or gets replaced by raw JSON-RPC over the vsock transport. + +**allowed_endpoints.yaml** = the RPC hostnames (Sui fullnode(s), HyperEVM RPC(s), later Seal key servers). The parent's forwarder only dials these. + +### Phase 2 — Reproducible build & PCR measurement (GitHub Actions, arm64) +1. Build the EIF via the nautilus reproducible Dockerfile: pinned Rust toolchain, base image pinned **by digest**, `cargo build --locked --release`, `SOURCE_DATE_EPOCH` set, no build timestamps. Goal: **bit-identical EIF → identical PCR0** across machines/runs. +2. `nitro-cli build-enclave --docker-uri --output-file signer.eif` → emits PCR0/1/2. Commit the expected PCR0 to the repo. +3. **CI drift gate:** rebuild on a clean runner and assert PCR0 == committed. Drift ⇒ the on-chain `EnclaveConfig` would reject the real enclave — catch it here, not at deploy. + +**Runner architecture — build the EIF NATIVELY on arm64, never x86+QEMU** (emulation is slow and breaks PCR determinism). GitHub *does* have arm64 hosted runners (the earlier "x86 only" belief is outdated), but for this **private** repo they require Team/Enterprise ("larger runners"). Two supported paths: +- **Team/Enterprise plan:** `runs-on: ubuntu-24.04-arm` (or a labeled arm64 larger runner). Native, clean. +- **Otherwise (default assumption):** a **self-hosted arm64 runner on a small Graviton box** (e.g. a `t4g`/`c7g` build instance — burstable is fine for *building*, it's only excluded for *running* enclaves). This box also reliably runs `nitro-cli build-enclave`. + +**Verify at build time:** whether `nitro-cli build-enclave` runs on a *hosted* runner without the `nitro_enclaves` kernel module. The build/measurement step generally does not need Nitro hardware, but if a hosted runner can't run it, that forces the self-hosted-Graviton path — so plan for the self-hosted runner as the safe default. + +**Pipeline outline** (`.github/workflows/bridge-enclave.yml`): +``` +jobs: + build-eif: + runs-on: [self-hosted, linux, arm64] # or ubuntu-24.04-arm on Team/Enterprise + steps: + - checkout + - build reproducible docker image (pinned digest, --locked, SOURCE_DATE_EPOCH) + - nitro-cli build-enclave --docker-uri $IMG --output-file signer.eif + - PCR0=$(jq -r .Measurements.PCR0 build-output.json) + - test "$PCR0" = "$(cat expected_pcr0.txt)" # drift gate — fail on mismatch + - on tag: docker push $IMG to ECR (pinned by digest) # host rebuilds the SAME EIF + - upload signer.eif + measurements as artifacts / into the EnclaveConfig manifest +``` +Ship the **image pinned by digest** (host `build-enclave`s the same digest → same EIF → same PCRs), or ship the EIF artifact directly. The measurements feed the governance step that sets `EnclaveConfig` PCRs on-chain (Phase 3). + +**Note on the existing arm backend workflows:** the other services already target arm (cross-compiled or on arm runners), but the enclave EIF is stricter — it needs a *native* arm64 build for reproducibility, so it can't ride an x86-runner + cross-compile path even if the plain services do. + +### Phase 3 — On-chain enclave registry (`enclave.move`) ✅ LANDED +Implemented in `sui-bridge-contracts/enclave/` (package `bridge_enclave`), adapted from nautilus `enclave.move` (Apache-2.0); attestation verification is native in the Sui framework (`sui::nitro_attestation`, confirmed present in the 1.71 toolchain). +1. `enclave::enclave` — `EnclaveConfig` (shared, governance-`Cap`-gated PCRs + version), `register_enclave(config, NitroAttestationDocument)` → per-node `Enclave` object holding the attested ephemeral pubkey; `verify_signature` for enclave-signed intents. ✅ +2. `enclave::signer` — the `BRIDGE_SIGNER` witness + `init` minting the governance `Cap`. ✅ +3. **Bridge additions over upstream:** `update_enclave_pk` re-registers a fresh ephemeral key into the SAME `Enclave` object (stable object id, so the ticket-08 Seal binding survives a restart — §6.4); owner-gated; register/update **events** for ticket-10 alerting; `destroy_old_enclave` version-gated retirement. ✅ +4. **Per-node operator credential:** `Enclave.owner` (the registrant) gates `update_enclave_pk`/`destroy_enclave_by_owner`; custody decision → ticket 10. +5. **Tested (7 Move tests):** PCR/version + cap gating, owner gating, stale-version retirement, bad-signature rejection, witness-cap minting. **NOT unit-testable here:** the `register_enclave`/`update_enclave_pk` attestation path needs a real `NitroAttestationDocument` (framework-native, real enclave + hardware) — integration-test in Phase 1/6. Test-only `deploy_for_testing` exercises the registry logic without a doc. + +### Phase 4 — Chain view inside the boundary (§5.4) +1. The ticket-02 `RpcVerifier` runs *in-enclave* with the ≥2-provider quorum, over the in-enclave-TLS transport from Phase 1. Carry forward the two live-run fixes: **bounded `eth_getLogs` lookback** (public RPCs cap the range) and tolerance for public-RPC 500s (retry/failover across the quorum providers). +2. Provider certs pinned in the enclave image (part of PCR measurement) so a swapped provider changes the PCRs. +3. Document the TCB honestly: at N=1 the enclave + its (pinned, in-enclave-verified) chain view is the trust root; the k-of-n guarantee arrives at ticket 09. + +### Phase 5 — Infra: Terraform (a new, isolated root) + +**Use a new Terraform root — do NOT extend `rust-backend/infra/`.** That root is flat, amd64/Ubuntu, local-state, and carries a known destructive-drift landmine (its `ecr.tf` `aws_ecr_repository.svc` for_each has `terraform state rm` warnings; a blanket `apply` there destroys the derived-metric-worker ECR repo + edits IAM — see the repo's terraform-drift notes). Isolating the enclave infra in its own root with its own state means we never have to `-target` around that, and the arch/OS differ anyway (arm64 + Nitro vs amd64). + +**New root: `rust-backend/infra-bridge/`** (own `versions.tf` with a **separate state backend key**, not shared with `infra/`). Read the existing network via data sources (or a `terraform_remote_state` data source against the main root's outputs) — reuse the VPC/subnet, don't recreate. + +Resources: +1. **`aws_ecr_repository "bridge_signer_enclave"`** — a standalone repo *in this root* (not the shared `svc` for_each map), which sidesteps the drift landmine entirely. (Missing repo → 403 on push, per the redeploy gotchas.) +2. **`aws_instance "bridge_signer"`**: + - `instance_type = "c7g.large"`, **`enclave_options { enabled = true }`**. + - `ami` = a **pinned Amazon Linux 2023 (or Ubuntu) arm64** AMI. Pin it, don't `most_recent` (matches the existing convention so a new release doesn't force-replace the host) — and it must be **arm64**, not the amd64 AMI the main root uses. + - `iam_instance_profile`, `vpc_security_group_ids`, `subnet_id` (data), `root_block_device` gp3 30+ GB. + - `user_data` (cloud-init, mirror the `infra/templates/` pattern): install `aws-nitro-enclaves-cli` + `-devel` + docker, add the user to the `ne`+`docker` groups, template **`/etc/nitro_enclaves/allocator.yaml`** (`cpu_count: 1`, `memory_mib: 1536`), `systemctl enable --now nitro-enclaves-allocator docker`, pull the image from ECR, `nitro-cli run-enclave`, and start the parent-side vsock forwarder + admin proxy. +3. **`aws_iam_role` + instance profile** (least-priv): ECR pull, `AmazonSSMManagedInstanceCore` (managed via SSM — **no SSH**, per repo ops conventions), CloudWatch Logs, and KMS decrypt only if the Seal/secrets path needs it. +4. **`aws_security_group`**: egress 443 to the RPC providers, Seal servers, ECR, SSM, and CloudWatch endpoints; ingress **tcp/3000 (signer public API) from the relayer's SG/CIDR only**. Admin **3001 stays host-local** (no SG ingress). No inbound 22. + +**Allocator math on c7g.large** (2 vCPU / 4 GB): 1 vCPU + ~1.5 GB to the enclave, leaving 1 vCPU + ~2.5 GB for the parent + vsock proxy. Comfortable for the signer workload. + +**N=1 now; module-ize for N≥3 later.** Write it as a small module (`bridge_signer_node`) even though we instantiate it once, so ticket 09's N=3 is `for_each` over three operator/subnet inputs. +`outputs.tf`: instance id, private IP, ECR repo URL, the SG id (for the relayer's egress rule). + +### Phase 6 — Lifecycle & boot +1. Lifecycle scripts: `build` (EIF + PCRs, in CI per Phase 2), `run` (`nitro-cli run-enclave`), the parent-side vsock forwarder + admin proxy, `attach-console` for debug builds only. +2. Boot flow: enclave starts → generates ephemeral Ed25519 key in-memory → `GET /get_attestation` returns the doc with that pubkey → operator calls `register_enclave` on-chain with their cap → signer flips to "ready". + +## Exit criteria +- Enclave runs on a Nitro EC2; `/get_attestation` returns a doc whose PCRs match the reproducible build and whose signature chain **verifies on-chain** via `register_enclave`. +- The signer **refuses `/sign_requests` until its ephemeral key is registered on-chain** (gate on the `Enclave` object existing). +- RPC egress reaches only allow-listed providers; a MITM'd provider (test harness presenting a wrong cert) fails in-enclave TLS pinning and **blocks signing** rather than yielding a forged chain view. +- End-to-end: a live testnet message is signed from inside the enclave and delivered both directions (re-run the ticket-04/round-trip flow with the enclave as signer). + +## Effort & sequencing +Largest single ticket in the M3 track. Rough phases: P1 (port + egress rework) ~1–2 wk — the in-enclave TLS transport is the crux; P2 (reproducible build + arm64 CI) ~few days; P3 (enclave.move) ~1 wk; P4 ~few days; P5 (terraform, isolated root) ~1 wk; P6 (lifecycle/boot) ~few days. Do P1–P2 before touching Move; P3 and P5 can proceed in parallel. + +**Depends on:** 02 (verifier runs in-enclave), 06 (async API is the enclave surface). **Blocks:** 08, 09. diff --git a/bridge_tickets/08-seal-share-provisioning.md b/bridge_tickets/08-seal-share-provisioning.md new file mode 100644 index 00000000..0c002467 --- /dev/null +++ b/bridge_tickets/08-seal-share-provisioning.md @@ -0,0 +1,57 @@ +# 08 — Seal key provisioning with per-node policy binding + +**Spec:** bridge-spec.md §5.2–§5.3, §6.4, **§6.5 (normative), §6.6** · **Milestone:** M3 · **Status:** not started (needs 07) +**Why:** signing keys are seeds in config today. Stateless enclaves (no persistent storage) mean a restart loses in-memory keys — so keys must be reloadable from **Seal** without any plaintext ever leaving the enclave. And the stock Nautilus Seal example gates decryption on **PCRs only**, which collapses k-of-n: every signer runs identical code → identical PCRs → any one operator's attested enclave could decrypt *every* node's share. This ticket implements the **per-node** policy (§6.5) and the 2-step in-enclave key load. + +**Recall the mechanics (verified against Seal docs + `seal_policy.move`):** Seal is Boneh-Franklin IBE on BLS12-381; identities are namespaced by the policy package id; `t`-of-`n` key servers each return an IBE-derived key share iff the package's `seal_approve*` Move function passes; the enclave can't reach the key servers directly (no egress) so the fetch is host-delegated in two steps, and responses are encrypted to the enclave's ephemeral ElGamal key so the host can't read them. + +--- + +## Detailed implementation plan + +### Phase 1 — The per-node Seal policy package (Move) — deliberately NOT the stock example +The stock `seal_policy.move` checks: `id == vector[0]`, sender == wallet pk, and an intent signature against **whichever `Enclave` object the caller passes**. That authorizes *any* attested instance — fine for one shared secret, unsafe for per-node shares. Ours: + +```move +// identity of share i = the 32-byte object id of node i's Enclave object. +entry fun seal_approve( + id: vector, // the requested identity (= node's Enclave id) + signature: vector, + wallet_pk: vector, + timestamp: u64, + enclave: &Enclave, + ctx: &TxContext, +) { + // (a) keep the example's three checks: sender == pk_to_address(wallet_pk), + // and an Ed25519 intent signature verifies against enclave.pk(). + // (b) ADD the binding: this share is decryptable only by THIS node. + assert!(object::id_to_bytes(&object::id(enclave)) == id, ENoAccess); +} +``` + +- **Publish the policy package IMMUTABLE** (or under a governance-only upgrade cap). Seal docs: an upgradeable package's owner can rewrite the access policy at any time. +- Test matrix is the point: (1) correct node + correct enclave → pass; (2) **node B's enclave requesting node A's identity → refused** (the k-collapse guard); (3) wrong sender, (4) bad/absent intent signature, (5) stale timestamp → all refused. + +### Phase 2 — Enclave-side 2-step key load (implement the ticket-06 admin stubs) +Today `router.rs` returns 501 for `/admin/init_seal_key_load` and `/admin/complete_seal_key_load`. Implement: +1. `init_seal_key_load` → the enclave generates an ephemeral **ElGamal** keypair (BLS group elements), builds a `FetchKeyRequest` = { the `seal_approve` PTB signed by the enclave wallet, the ElGamal pubkey }, returns it (BCS/hex) to the host. +2. Host helper POSTs the `FetchKeyRequest` to each configured Seal key server (`/v1/fetch_key`); each server dry-runs `seal_approve` and, if it passes, returns its key **share encrypted to the ElGamal pubkey**. +3. `complete_seal_key_load` ← the host hands the (still-encrypted) server responses back in; the enclave ElGamal-decrypts them, IBE-combines the `t` shares into the derived key, and uses it to decrypt the node's signing material. **All plaintext stays in enclave memory.** +- Key server set + threshold from config: **Mysten open testnet servers, t=1** acceptable for testnet; the mainnet set/threshold is a ticket-10 decision (§6.6). + +### Phase 3 — What gets provisioned, and recovery +1. **Ciphertext creation (one-time per node):** encrypt node *i*'s signing material to identity `[Enclave_i object id]` under the policy package. At M1-in-enclave that's the two curve seeds; at M3 (ticket 09) it's the DKG *shares* — same mechanism, different payload. Store ciphertexts anywhere (Walrus, S3, repo-adjacent): they're small and useless without both the policy AND that node's enclave. +2. **Restart:** enclave boots → re-run the 2-step load → shares back in memory. Nothing regenerated; no new DKG. +3. **Replacement / hardware loss (§6.4):** provision a fresh enclave with identical PCRs → it has a *new* ephemeral key → operator calls `update_enclave` (ticket 07) with their cap to register the new pubkey into node *i*'s `Enclave` object → key load now passes (identity still = that `Enclave` object id). **Identical PCRs alone are deliberately insufficient** — the explicit, on-chain-visible re-registration is the point. +4. **Key-server rotation:** the server set is frozen per ciphertext, so rotating servers = re-encrypt each node's material to the new set (cheap — the payload is tiny). This is the envelope-encryption pattern Seal itself recommends. + +## Exit criteria +- Policy tests green, **including the cross-node negative test** (node B cannot fetch node A's share) and the wrong-sender / bad-signature / stale-timestamp refusals. +- On testnet: kill an enclave, restart it, and it recovers its signing keys via the 2-step Seal load with **zero plaintext key material anywhere outside enclave memory** (config seeds removed from the deployment entirely). +- Replacement drill: destroy the instance, provision fresh, operator re-registers the `Enclave` object, key load succeeds, signing resumes — timed and runbooked. +- An alert (`alert_id`, repo convention) fires on any `Enclave` object re-registration (anomalous ones are an attack signal). + +## Effort & sequencing +~1–1.5 wk once 07 exists. Phase 1 (policy + tests) can start on a laptop against a local Sui + the Seal Move libs before the enclave is ready; Phases 2–3 need the running enclave. The k-collapse negative test is the deliverable that most matters for the trust model. + +**Depends on:** 07 (enclave + `Enclave` objects + operator caps). **Blocks:** 09 (DKG shares are provisioned via this mechanism). diff --git a/bridge_tickets/09-threshold-signing-dkg.md b/bridge_tickets/09-threshold-signing-dkg.md new file mode 100644 index 00000000..348c07c8 --- /dev/null +++ b/bridge_tickets/09-threshold-signing-dkg.md @@ -0,0 +1,46 @@ +# 09 — Real threshold crypto: FROST + ECDSA-MPC, DKG, N≥3 + +**Spec:** bridge-spec.md §6 (M3 exit) · **Status:** not started (needs 07+08) +**Why:** `bridge-signer`'s `ThresholdSigner` today is literally one keypair per curve — the "threshold" is a name. The trust model's headline guarantee (no single compromised node forges a message) only exists once the group key is produced by a DKG and signing is k-of-n with shares that never combine into a full key. **Nothing on-chain changes:** the `SignatureEnvelope` (`scheme_tag`, `group_pubkey_id`, `signature`), the `GroupKeyRegistry`/`registerGroupKey` rotation seam, and the async session API (ticket 06) were all built for exactly this swap. + +**The on-chain verify paths are already proven** to accept a real aggregated signature: an Ed25519 signature verifies on the live Sui Inbox and an ECDSA one on the live HyperEVM Inbox (the round trip in DEPLOYMENTS.md). FROST/GG20 must simply produce byte-identical-shaped signatures. + +--- + +## Detailed implementation plan + +### Phase 1 — Library selection (resolve §6.1 in writing FIRST) +Do NOT roll your own threshold crypto. Produce a short written comparison + pick, then schedule the external review (ticket 10). +- **Ed25519 (FROST, 2-round):** default to the ZF `frost-ed25519` family (`frost-core` + `frost-ed25519`), which is audited and maintained. **Must verify:** the aggregated signature is RFC-8032-compatible so Sui's `ed25519_verify` accepts it unchanged (write a test: FROST-aggregate → `ed25519_verify` in a Move test using a known group key). +- **ECDSA/secp256k1 (GG20 / CGGMP, multi-round):** the harder choice — the MPC-ECDSA crate landscape is uneven. Selection criteria: current maintenance, prior audit, **identifiable aborts** (a misbehaving party is pinpointed and ejected, not just a stalled round), and **safe concurrent signing sessions** (multiple `message_hash` sessions in flight — GG20 has known concurrency footguns). Must verify: aggregated sig recovers to the registered group address via `ecrecover` (test against the EVM Inbox path). +- Output: `CRYPTO_CHOICE.md` with the two crates, versions, audit links, and the concurrency/abort posture. + +### Phase 2 — DKG ceremony tooling (×2, one per curve) +1. Run a Pedersen/GJKR-style DKG (or FROST's own trusted-dealer-free DKG) across the party set. Parties = the N Nautilus enclaves (+ any human bootstrap parties — **resolve §9.3 first: are humans bootstrap-only or permanent share-holders?** it changes their custody story). +2. Each party finishes holding a **verified share**; the group public key falls out; **no full private key ever exists anywhere**. Each enclave party's share is immediately Seal-encrypted to its per-node identity via the ticket-08 mechanism. +3. Register the two group pubkeys on **both** chains via `registerGroupKey` under a **new `group_pubkey_id`**, and `setSignerThreshold(k, n)`. Keep the old 1-of-1 id registered until cutover. +4. Ceremony must be reproducible/auditable (transcript logged); this procedure is in the ticket-10 audit scope. + +### Phase 3 — MPC transport (libp2p mesh) +1. A libp2p P2P mesh between the signer enclaves over **mutually-attested, authenticated channels**: before accepting any round message, each node verifies the peer's Nautilus attestation + its on-chain `Enclave` registration (reuse ticket-07's on-chain registry as the peer allow-list). +2. An **untrusted coordinator/relay for liveness only** — it queues/forwards round messages but cannot forge them (every round message is signed by a share-holder's enclave key). It can stall, never corrupt. +3. At N=1 there is no mesh; the transport turns on as N grows — no contract change. + +### Phase 4 — Wire threshold signing behind the async API +The ticket-06 session store becomes real: `POST /sign_requests` on any node opens/joins a session keyed by `message_hash`; the coordinator gathers a signing set of k nodes; they run the FROST (2-round) or GG20 (multi-round) protocol; the aggregated signature lands in the session for `GET /sign_requests/:hash` polling. **Each participating node independently runs its own ticket-02 §5.4 verification before contributing a share** — so a forged source view has to fool k independent enclaves, not one. This is where the "verify-before-admit" and idempotent-session design pays off. + +### Phase 5 — Rollout to N=3, k=2 on testnet +1. Stand up three enclaves — ideally under **≥2 distinct operators**, because a threshold where one entity holds all operator caps is organizational fiction (spec §1). +2. Rotate both Inboxes to the new DKG group keys (`registerGroupKey` new id, point the relayer/signer at it), then retire the 1-of-1 keys. + +## Exit criteria (spec M3 exit) +- Group keys generated by DKG with a **code-reviewed no-reconstruction invariant** — grep the signing path: no code ever assembles the full private key. +- k-of-n signing live on testnet **both directions**, verifying through the unchanged on-chain paths (re-run the round trip with the threshold signer). +- **One-node-down tolerated:** kill a node mid-traffic, signing continues at k=2. +- A node **refuses rounds from an unattested/unregistered peer** (negative test). +- **Concurrent sessions:** parallel messages sign correctly without cross-session interference (the GG20 concurrency criterion, demonstrated). + +## Effort & sequencing +The other large M3 ticket. Phase 1 (selection + compatibility tests) is doable now on a laptop and de-risks everything — **do it first, even before 07/08 finish**, since a bad ECDSA-MPC crate choice is expensive to unwind. Phases 2–5 need the enclaves (07) and Seal provisioning (08). + +**Depends on:** 06 (async surface), 07 (enclaves + attested peer identity), 08 (share provisioning). **Blocks:** 10. diff --git a/bridge_tickets/10-lifecycle-hardening.md b/bridge_tickets/10-lifecycle-hardening.md new file mode 100644 index 00000000..acf97b3d --- /dev/null +++ b/bridge_tickets/10-lifecycle-hardening.md @@ -0,0 +1,55 @@ +# 10 — Lifecycle, governance & hardening (M4 / audit-ready) + +**Spec:** bridge-spec.md §8 M4, §9 open items · **Status:** not started · **Depends on:** effectively 01–09 · **Blocks:** mainnet +**Why:** everything before this makes the bridge *work*; this makes it *operable, governable, and auditable*. It collects the decisions deliberately deferred through 01–09 and turns the demo posture (deployer EOAs, demo keys, placeholder finality) into production posture. + +--- + +## Detailed implementation plan + +### 1. Governance & guardian topology +Today both chains sit on deployer EOAs (`0xab8d…` on Sui, `0x303c…` on EVM — see DEPLOYMENTS.md) and the round trip used **demo group keys whose seeds are in the repo history** (`[0x42]`/`[0x11]`). Production: +1. **Sui:** transfer `GovernanceCap` + `GuardianCap` to multisig-controlled addresses (Sui multisig or a governance object). **EVM:** point Registry `governance`/`guardian` at a Safe (or equivalent). Decide the topology (m-of-n, who) per role — guardian (pause, fast) is usually a smaller/faster set than governance (registry/keys/threshold). +2. **Operator-cap custody (§9.4):** each node's `Enclave`-registration cap (ticket 07) is the per-node credential. Decide hardware-wallet vs per-operator multisig, document per operator. Stealing this cap = pointing that node's identity at an attacker enclave, so it's as sensitive as a signer key. +3. **Retire the demo keys:** the live testnet group keys must be replaced by the ticket-09 DKG keys (or, if staying 1-of-1 longer, keys whose seed is in the enclave/Seal, never the repo). `registerGroupKey` a real id, retire id 1/2. +4. **Pause drill:** from the *real* multisig, pause + unpause each of the 4 boxes (Sui/EVM Inbox+Outbox) and a Locker; runbook it. + +### 2. HyperEVM finality confirmation (§9.1) — the placeholder we've been carrying +Confirm HyperEVM finality semantics + the **dual-block architecture** (frequent small blocks vs ~1/min big blocks, separate gas limits) against current Hyperliquid docs. Then: +- Set the confirmation depth in the chain registries + the signer's `confirmations` config from **evidence, not the placeholder `12`**. +- Determine which block type our Inbox/Locker txs land in and the gas implications. +- Revisit the ticket-04 finding: the signer's `EvmProbe` uses a bounded `lookback_blocks` window; make sure that window comfortably exceeds `confirmations` + realistic relay delay, and note public-RPC `eth_getLogs` range caps (we hit `max block range 1000`). + +### 3. Production RPC & relayer economics +- **Dedicated RPCs:** the live run showed the shared `rpcs.chain.link` endpoint rate-limits (`ErrUpstreamsExhausted` / HTTP 500) under sustained relayer polling. Production needs dedicated/authenticated RPC endpoints for both the relayer watchers and the in-enclave verifier quorum — and the verifier needs **≥2 independent** ones (§5.4), so budget for that. +- **Relayer economics (§9.6):** v1 = self-relay (we run the relayer, eat destination gas). Confirm + document, or design a fee mechanism. Also implement the deferred `is_delivered` for the Sui submitter (a `devInspect` `inbox::is_consumed` check) so re-deliveries pre-skip instead of failing a tx. + +### 4. Mainnet Seal key-server set (§9.5, §6.6) +Testnet uses Mysten's open servers at t=1. For mainnet: choose vetted independent operators at **t ≥ 2** (or a committee-mode MPC key server), establish availability/SLA agreements (Seal's own "treat key-server selection as a trust decision"), and re-encrypt every node's share ciphertext to the chosen set. Remember: a colluding quorum of `t` key servers can derive any share ciphertext — this layer sits *above* the bridge's k-of-n. + +### 5. Rotation & recovery runbooks (§6.4) — drilled, not just written +Each executed once on testnet by the person who'd do it in prod: +- **Re-share** (rotate shares, group key fixed) AND **full rotation** (fresh DKG → `registerGroupKey` new id → retire old). +- **Node replacement** end-to-end (ticket-08 flow: fresh enclave → operator re-register → Seal reload → resume), with a timing target. +- **Relayer cursor / stuck-message** recovery (the EVM watcher `from_block` cursor, the dedup-only redelivery behavior). +- **Emergency:** guardian pause → investigate → unpause. + +### 6. Observability & alerting (repo `.claude/tx-alerting.md` convention) +Every service tx-submission failure carries an `alert_id` (already done in the relayer: `tx-failed-bridge-relay`). Extend coverage: +- Signer availability / verifier **provider disagreement** (a split quorum vote is a strong tamper signal). +- **`Enclave` re-registration** events (anomalous ones = attack signal). +- **Rate-limit-queue growth** on both Lockers (queued transfers piling up). +- **The one alert that catches everything:** a cross-chain **supply-invariant monitor** — `wrapped_supply_on_foreign ≤ locked_collateral_on_home` per asset per route. If this ever breaks, something upstream (signer, verifier, a contract bug) already failed. Wire it into the existing Prometheus/Grafana + balance-monitor stack. + +### 7. Security review (§8 M4) +- **External audit scope:** Move packages (messaging, locker, seal-policy, enclave registration), Solidity (messaging + locker), the **Nautilus fork delta** (upstream is explicitly unaudited — hence the `FORK_DELTA.md` from ticket 07), the chosen MPC libraries + our integration (session handling, abort behavior, the no-reconstruction invariant), and the DKG ceremony transcript/procedure. +- **Internal pre-audit pass:** an adversarial test suite that has a red-team case for **every row of the bridge-spec §1 trust table** (message authenticity, share confidentiality, honest-code attestation, no reconstruction, liveness, replay, source truth, chain-view integrity, per-node isolation, key-server honesty). + +## Exit criteria (spec M4 exit) +- Runbook-complete: every operational procedure above executed at least once on testnet by its real operator. +- All governance/guardian actions require the real multisigs; demo keys retired. +- Alert coverage demonstrated by **fault injection** (kill a signer, wedge the relayer, force a provider disagreement, push an over-limit transfer, attempt an `Enclave` re-register). +- Audit engagement scoped + scheduled; findings triaged to closure **before any mainnet value flows**. + +## Effort & sequencing +Spread across the M3 work rather than a single block — items 1/2/6 can start as soon as the relevant pieces exist; 7 (audit) is the long-pole calendar item, so scope + book it early. This ticket is "done" when the bridge could carry real value with a straight face. diff --git a/bridge_tickets/README.md b/bridge_tickets/README.md new file mode 100644 index 00000000..40bb3691 --- /dev/null +++ b/bridge_tickets/README.md @@ -0,0 +1,38 @@ +# Bridge tickets + +Work remaining to complete the cross-chain messaging layer + lock-and-mint bridge, per `bridge-spec.md` v0.2 and the 2026-07-01 code audit. One file per ticket; each has scope, verify/exit criteria, and dependencies. + +## Order & dependencies + +``` +01 domain-separator ──► 02 rpc-source-verifier ──► 06 async-signing-api ──► 09 threshold-signing-dkg ──► 10 lifecycle-hardening + │ │ ▲ ▲ + ├──► 03 evm-locker ──► 04 relayer-evm-to-sui │ │ + │ │ │ │ + │ └──► 05 sui-rate-limit-queue │ │ + │ │ │ + └──────────────────► 07 nautilus-enclave ─────────┴──► 08 seal-share-provisioning +``` + +| # | Ticket | Spec milestone | One-liner | +|---|--------|----------------|-----------| +| 01 | [domain-separator](01-domain-separator.md) | M1 gap | ✅ **DONE** — `DOMAIN_SEP` in the digest, all 3 impls, both chains redeployed live, parity verified | +| 02 | [rpc-source-verifier](02-rpc-source-verifier.md) | M1 gap | ✅ **DONE** — `RpcVerifier` (all-provider quorum, EVM+Sui probes), anvil-verified; `trust_all` dev-gated | +| 03 | [evm-locker](03-evm-locker.md) | M2 | ✅ **DONE** — `Locker.sol` escrow/mint + `WrappedToken`, queue-on-rate-limit, 16 tests + anvil deploy | +| 04 | [relayer-evm-to-sui](04-relayer-evm-to-sui.md) | M1/M2 | ✅ **DONE** — EVM watcher + type-derived Sui submitter + BCS layer + router; **live HyperEVM→Sui→HyperEVM round trip on testnet** (M2 exit) | +| 05 | [sui-rate-limit-queue](05-sui-rate-limit-queue.md) | M2 | ✅ **DONE** — Sui Locker queues over-limit transfers + permissionless claim, matches EVM; 13 tests | +| 06 | [async-signing-api](06-async-signing-api.md) | pre-M3 | ✅ **DONE** — `POST /sign_requests` + poll-by-hash, idempotent sessions, verify-before-admit, per-IP limit; live-smoked | +| 07 | [nautilus-enclave](07-nautilus-enclave.md) | M3 | Signer inside AWS Nitro: attestation on-chain, TLS-in-enclave chain view | +| 08 | [seal-share-provisioning](08-seal-share-provisioning.md) | M3 | Per-node Seal policy (§6.5) + 2-step key load; restart/replacement recovery | +| 09 | [threshold-signing-dkg](09-threshold-signing-dkg.md) | M3 | FROST + ECDSA-MPC libs, dual DKG, attested libp2p mesh, N=3 k=2 | +| 10 | [lifecycle-hardening](10-lifecycle-hardening.md) | M4 | Governance multisigs, HyperEVM finality confirmation, runbooks, alerting, audit | + +## Already done (for context) + +M0 + most of M1: three-way parity message format, Sui Outbox/Inbox with hot-potato delivery (`consume(&UID)`), Solidity Outbox/Inbox with callback delivery, chain/group-key registries with rotation, Sui Locker (escrow/mint, peers, decimals normalization), 1-of-1 signer service, Sui→EVM relayer — deployed to Sui + HyperEVM testnets 2026-06-29 (`sui-bridge-contracts/DEPLOYMENTS.md`). + +## Conventions + +- Tickets 01+02 before anything else touches contracts or accumulates signed traffic. +- Every ticket's exit criteria include tests; cross-language behavior (digest, payload wire format, queue semantics) uses shared test vectors in all implementations, same discipline as the existing known-digest vector. +- Tx-submitting services follow `.claude/tx-alerting.md` (`alert_id` on submission failures). diff --git a/frontend/.design-sync/NOTES.md b/frontend/.design-sync/NOTES.md new file mode 100644 index 00000000..30902ebc --- /dev/null +++ b/frontend/.design-sync/NOTES.md @@ -0,0 +1,37 @@ +# design-sync notes — tideline-frontend + +Repo-specific gotchas for future syncs. The DS package is `frontend/` (run all design-sync commands from there). + +## Source shape +- This is a **Vite app, not a library** — there is no `dist/` library entry and no `exports` in `package.json`. The converter runs in **synth-entry mode**: `cfg.entry = "__nodist__.js"` (a deliberately nonexistent path) forces `resolveDistEntry(soft)→null` so it synthesizes an entry from `src/`, while the PKG_DIR walk-up still lands on `frontend/package.json`. +- `cfg.srcDir = "src/components"` scopes discovery to the 26 component files (yields 28 components — a couple of files export more than one). Bundling still follows imports out to `../api`, `../state`, `../config`, `../session`, etc. + +## Bundle size — the WalletConnect stub (load-bearing) +- The full app dependency graph is ~12 MB of input → 5.7 MB IIFE, **over claude.ai/design's 5 MB hard cap**. +- The entire overage is the WalletConnect stack — `@reown/appkit*` (~5 MB), `@walletconnect/utils` (1.5 MB), `viem` (1.2 MB), `poseidon-lite` (0.6 MB) — pulled in solely by `session/wallets.ts`'s dynamic `import("@walletconnect/ethereum-provider")`. `session/store.ts` imports `wallets.ts` statically, and `store.ts` is reached by Header, TradePanel, OpenOrdersSection, SessionMenu — so the stack rides in even without SessionMenu. +- **Fix:** `.design-sync/tsconfig.dssync.json` (set as `cfg.tsconfig`) aliases `@walletconnect/ethereum-provider` → `.design-sync/wc-stub.ts` via `compilerOptions.paths`. The converter's `tsconfigPathsPlugin` reads paths from that file **literally (does NOT follow `extends`)**, so the alias must live directly in it. Bundle drops to ~1.65 MB. The real package is uninstalled — the stub makes it unnecessary, and the WC connect path only runs on user click, never in a static preview. +- The two stub files (`wc-stub.ts`, `tsconfig.dssync.json`) are committed durable inputs. Do not delete them or the bundle goes back over 5 MB. + +## Providers / theme (for previews — TBD) +- App provider stack (`src/main.tsx`): PostHogProvider → QueryClientProvider → SuiClientProvider → WalletProvider → BrowserRouter. +- Theme is attribute-driven: `data-mode` (`light`/`dark`, default dark) on `` via `src/theme.ts`; **`aqua.css` tokens are scoped under `[data-theme="aqua"]`**, set on a wrapper `
` in `App.tsx`. Previews need that wrapper or every `--aqua-*` token is unresolved → unstyled. +- Token catalog (`config.ts` `findToken`) is populated by async `initConfig()` (network fetch from token-info) — not available in headless preview; author previews with explicit props instead. +- Fonts: Sora + JetBrains Mono are loaded from Google Fonts via a `` in `index.html` (not shipped). Expect `[FONT_MISSING]` — resolve via remote `@import` or `runtimeFontPrefixes`. + +## Previews (authored §4) +- **Provider** (`.design-sync/ds-provider.tsx`, cfg.provider=DSProvider, cfg.extraEntries): wraps every preview in QueryClientProvider → SuiClientProvider → WalletProvider → MemoryRouter → `
`. Renders in the app's **dark** mode (set at module load + a `useLayoutEffect`). The wrapper has `transform: translateZ(0)` so `position:fixed`/sticky bits (toast, header, modal scrims) are contained in-card instead of escaping to the viewport. + - **Consequence for modals**: the transform makes the modal's `position:fixed; inset:0` scrim size to the wrapper, not the viewport. ActionModal/ConfirmModal previews therefore wrap the modal in a tall in-flow `Frame` spacer (860 / 680px) so the wrapper grows and the centered modal isn't clipped. Don't remove the Frame. +- **Authored previews**: 27 components in `.design-sync/previews/`, all graded good. Import the component from `"tideline-frontend"` (shimmed to `window.Tideline`); pass realistic props — no provider/theme/data-theme in the preview itself. +- **SessionMenu**: floor card (no preview). It returns `null` unless `useSession().handle` is set and the session store has no injection seam, so it can't render in a static preview without seeding the store from the provider. Left as the deliberate floor baseline. +- **Backend-gated states (graded good as legitimate states, not failures)**: ChartPanel ("No quotes yet" chrome), IndexerProgressBar ("indexer status unavailable"), LiveBuckets ("failed to fetch"), TradePanel (BalanceManager setup card), Orderbook ("book is empty"), OpenOrdersSection ("enable trading" prompt), ConnectMenu (disconnected connect pill). Their populated states need live api-service / DeepBook / a connected wallet, which previews don't have. To upgrade them later, seed react-query in DSProvider with `setQueryData` for keys like `["deepbook-book", poolId]`, the `/indexer/progress` + `/buckets` fetches, and `useBars` — but that's a provider change and re-invalidates ALL grades. +- **Pyth SSE gotcha**: `usePythPrice`/`usePythPrices` open a persistent Hermes EventSource; with a resolvable symbol the capture's networkidle `page.goto` times out (20s). Keep Pyth-driven previews on `symbol={null}` (BucketBar does this). +- **cfg.overrides**: StrikeTiles `cardMode:column` (wide tile row); Header `cardMode:single` (full-width nav); Toast `cardMode:single`+primaryStory Success (fixed-position escapes the grid); ActionModal/ConfirmModal `cardMode:single`+viewport (tall modals). + +## Re-sync risks (watch-list) +- **dtsPropsFor in config are hand-inlined snapshots** of `src/types.ts` + `src/api/client.ts` shapes (Strike, Quote, Bucket, Series, OwnedPosition, WrittenPosition, ConfirmSummary, DashboardModal). If those domain types change, update `cfg.dtsPropsFor` — the synth-entry DTS extractor only produces `[key:string]:unknown`, so these are maintained by hand. +- **Fonts** are a pinned Google Fonts woff2 snapshot (Sora + JetBrains Mono, latin/latin-ext) under `.design-sync/fonts/`. Re-fetch if the brand font set changes. +- **WC stub** must stay (`wc-stub.ts` + `tsconfig.dssync.json`) or the bundle exceeds 5 MB again. +- Backend-gated previews are tied to current component data-fetching code; if a component switches from internal fetch to props, its preview can be upgraded to a populated state. + +## Target project +- Synced into the pre-existing hand-built project `019e2eaa-b787-7142-9424-594bf76b8d24` ("Tideline — Sui Options Design System") at the user's explicit request (atomic path, non-empty target). It already contains a hand-curated `ui_kits/`, `preview/`, `assets/`, `colors_and_type.css`, `SKILL.md` — those are NOT converter output; the delete list must be confirmed with the user before reconciliation. diff --git a/frontend/.design-sync/config.json b/frontend/.design-sync/config.json new file mode 100644 index 00000000..253bda77 --- /dev/null +++ b/frontend/.design-sync/config.json @@ -0,0 +1,68 @@ +{ + "projectId": "019e2eaa-b787-7142-9424-594bf76b8d24", + "shape": "package", + "pkg": "tideline-frontend", + "globalName": "Tideline", + "entry": "__nodist__.js", + "srcDir": "src/components", + "tsconfig": ".design-sync/tsconfig.dssync.json", + "cssEntry": "src/styles/aqua.css", + "extraFonts": ".design-sync/fonts/fonts.css", + "extraEntries": [ + "./.design-sync/ds-provider.tsx" + ], + "provider": { + "component": "DSProvider" + }, + "overrides": { + "StrikeTiles": { + "cardMode": "column" + }, + "Header": { + "cardMode": "single" + }, + "ActionModal": { + "cardMode": "single", + "viewport": "720x920" + }, + "ConfirmModal": { + "cardMode": "single", + "viewport": "560x760" + }, + "Toast": { + "cardMode": "single", + "primaryStory": "Success" + } + }, + "dtsPropsFor": { + "Toast": "message: string;\n variant?: \"success\" | \"error\" | \"info\";", + "StrikeTiles": "strikes: Array<{ strike: number; perUnit: number; premium: number; premiumDisplay: string }>;\n selectedIdx: number;\n onSelect: (i: number) => void;\n view: \"writer\" | \"trader\";", + "AmountInput": "amount: number;\n setAmount: (n: number) => void;\n view: \"writer\" | \"trader\";\n /** Underlying-asset symbol (on-chain ticker, e.g. `TBTC`); null while loading. */\n assetSymbol: string | null;\n btcBalance: number;\n usdcBalance: number;\n error: string;\n /** Live spot price (settlement per 1 underlying); 0/absent disables the USDC toggle. */\n spot: number;\n /** Settlement-asset symbol (e.g. `USDC`) for the alternate denomination. */\n settlementSymbol: string;", + "BucketBar": "symbol: string | null | undefined;\n assets: Array<{ symbol: string; decimals: number | null }>;\n selectedAsset: string | null;\n onSelectAsset: (symbol: string) => void;\n expiries: Array<{ ms: number; iso: string }>;\n selectedExpiryMs: number | null;\n onSelectExpiry: (ms: number) => void;\n settlementSymbol: string;", + "ChainTable": "buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }>;\n strikes: Array<{ strike: number; perUnit: number; premium: number; premiumDisplay: string }>;\n series: { asset_symbol: string; asset_decimals: number | null; asset_coin_type: string; settlement_symbol: string; settlement_decimals: number | null; settlement_coin_type: string; expiry_ms: number; expiry_iso: string; buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }> };\n spot: number;\n selectedIdx: number;\n onSelect: (i: number) => void;", + "BuyDetailTabs": "bucket: { bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean };\n series: { asset_symbol: string; asset_decimals: number | null; asset_coin_type: string; settlement_symbol: string; settlement_decimals: number | null; settlement_coin_type: string; expiry_ms: number; expiry_iso: string; buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }> };\n spot: number;\n mid: number | null;\n wallet: string | null;\n tab: \"greeks\" | \"details\" | \"book\" | \"orders\";\n onTabChange: (tab: \"greeks\" | \"details\" | \"book\" | \"orders\") => void;", + "BuyModeToggle": "mode: \"deepbook\" | \"mm\";\n onChange: (m: \"deepbook\" | \"mm\") => void;", + "QuoteFeed": "quotes: Array<{ id: string; name: string; addr: string; fill: number; revertRate: number; latency: number; premium: number; ttl: number; arrivedAt: number }>;\n view: \"writer\" | \"trader\";\n onClose?: () => void;\n docked?: boolean;", + "Tideline": "bucket: { cursor: number; queued: number; cap: number };\n amount: number;\n assetSymbol: string | null;", + "TraderPanels": "premium: number;\n premiumLoading: boolean;\n amount: number;\n strike: number;\n spot: number;\n assetSymbol: string | null;\n expiryLabel: string;", + "WriterPanels": "premium: number;\n premiumLoading: boolean;\n amount: number;\n strike: number;\n assetSymbol: string | null;\n expiryLabel: string;", + "ChartPanel": "poolId: string;\n /** Bucket strike in quote units, for the reference line. null hides it. */\n strike: number | null;\n settlementSymbol: string;", + "PayoffChart": "strike: number;\n qty: number;\n totalCost: number;\n breakEven: number;\n spot: number;", + "VaultApyChart": "realized: Array<{ t_ms: number; apy: number; apy_low?: number; apy_high?: number; assignment_prob?: number; downside_round_yield?: number; kind?: string; confidence?: number }>;\n predicted: Array<{ t_ms: number; apy: number; apy_low?: number; apy_high?: number; assignment_prob?: number; downside_round_yield?: number; kind?: string; confidence?: number }>;\n loading: boolean;", + "WaveLoader": "className?: string;", + "IndexerProgressBar": "", + "LiveBuckets": "", + "Header": "", + "SessionMenu": "", + "OwnedCard": "p: {\n id: string; side: \"owned\"; asset: \"BTC\" | \"SUI\" | string; strike: number; expiry: string; amount: number;\n premiumPaid: number; boughtFrom: string; boughtAt: string; rangeId: string; tradingAccountAmount: number;\n lots: Array<{ amount: number; cost: number; source: \"rfq\" | \"deepbook\" | \"transfer\"; acquiredAtMs: number }>;\n spot: number; dte: number; itm: boolean; moneyness: number; intrinsicNow: number; pnl: number;\n realizedPnl: number; totalPnl: number; unpricedExerciseAmount: number;\n status: \"exercisable\" | \"active_otm\" | \"expired_itm\" | \"expired_otm\";\n };\n onExercise: (p: OwnedCardProps[\"p\"]) => void;\n onWithdraw: (p: OwnedCardProps[\"p\"]) => void;", + "WrittenCard": "p: {\n id: string; side: \"written\"; asset: \"BTC\" | \"SUI\" | string; strike: number; expiry: string; amount: number;\n premiumReceived: number; soldTo: string; soldAt: string; rangeStart: number; rangeEnd: number;\n cursorAtSale?: number; cursorAtExpiry?: number; spot: number; dte: number; exercisedQty: number;\n totalQty: number; exercisedPct: number; cursor: number;\n status: \"claimable\" | \"active\" | \"partially_exercised\" | \"fully_exercised\";\n };\n onClaim: (p: WrittenCardProps[\"p\"]) => void;", + "ActionModal": "modal:\n | { kind: \"exercise\"; stage: \"signing\" | \"broadcast\" | \"confirmed\" | \"review\" | null; position: {\n id: string; side: \"owned\"; asset: \"BTC\" | \"SUI\" | string; strike: number; expiry: string; amount: number;\n premiumPaid: number; boughtFrom: string; boughtAt: string; rangeId: string; tradingAccountAmount: number;\n lots: Array<{ amount: number; cost: number; source: \"rfq\" | \"deepbook\" | \"transfer\"; acquiredAtMs: number }>;\n spot: number; dte: number; itm: boolean; moneyness: number; intrinsicNow: number; pnl: number;\n realizedPnl: number; totalPnl: number; unpricedExerciseAmount: number;\n status: \"exercisable\" | \"active_otm\" | \"expired_itm\" | \"expired_otm\";\n }; qty: number }\n | { kind: \"claim\"; stage: \"signing\" | \"broadcast\" | \"confirmed\" | \"review\" | null; position: {\n id: string; side: \"written\"; asset: \"BTC\" | \"SUI\" | string; strike: number; expiry: string; amount: number;\n premiumReceived: number; soldTo: string; soldAt: string; rangeStart: number; rangeEnd: number;\n cursorAtSale?: number; cursorAtExpiry?: number; spot: number; dte: number; exercisedQty: number;\n totalQty: number; exercisedPct: number; cursor: number;\n status: \"claimable\" | \"active\" | \"partially_exercised\" | \"fully_exercised\";\n } }\n | null;\n spots: Record;\n onSubmit: () => void;\n onClose: () => void;", + "ConfirmModal": "stage: \"signing\" | \"broadcast\" | \"confirmed\" | null;\n summary: {\n view: \"writer\" | \"trader\"; premium: number; bucket: string; rangeStart: number; rangeEnd: number;\n amount: number; strike: number; asset: string; expiry: string;\n } | null;\n view: \"writer\" | \"trader\";\n onClose: () => void;", + "TokenLogo": "symbol: string | null | undefined;\n className: string;\n fallback: React.ReactNode;", + "ConnectMenu": "onSuiWallet: () => void;", + "TradePanel": "bucket: { bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean };\n series: { asset_symbol: string; asset_decimals: number | null; asset_coin_type: string; settlement_symbol: string; settlement_decimals: number | null; settlement_coin_type: string; expiry_ms: number; expiry_iso: string; buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }> };", + "Orderbook": "bucket: { bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean };\n series: { asset_symbol: string; asset_decimals: number | null; asset_coin_type: string; settlement_symbol: string; settlement_decimals: number | null; settlement_coin_type: string; expiry_ms: number; expiry_iso: string; buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }> };", + "OpenOrdersSection": "bucket: { bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean };\n series: { asset_symbol: string; asset_decimals: number | null; asset_coin_type: string; settlement_symbol: string; settlement_decimals: number | null; settlement_coin_type: string; expiry_ms: number; expiry_iso: string; buckets: Array<{ bucket_id: string; strike: number | null; strike_raw: string; call_coin_type: string; strike_scale: number; total_written: number | null; total_written_raw: string; exercise_cursor: number | null; exercise_cursor_raw: string; fill_pct: number | null; invalidated: boolean; deepbook_pool_id: string | null; tradeable: boolean }> };" + }, + "readmeHeader": ".design-sync/conventions.md" +} diff --git a/frontend/.design-sync/conventions.md b/frontend/.design-sync/conventions.md new file mode 100644 index 00000000..fa807be1 --- /dev/null +++ b/frontend/.design-sync/conventions.md @@ -0,0 +1,63 @@ +# Tideline ("Aqua") — how to build with this design system + +Tideline is the UI of a Sui on-chain options protocol. Its look is **"Aqua"**: frosted glass on a deep underwater gradient, Sora for display, JetBrains Mono for numerals. Components are real React parts that assume the app's runtime context — wire it up exactly as below or they render unstyled or throw. + +## 1. Wrapping & setup (required) + +Every screen must mount inside the provider stack **and** under a `data-theme="aqua"` element, with the mode set on ``: + +```tsx +// document.documentElement.dataset.mode = "dark" // "dark" (default) | "light" + + + + +
{/* your screen */}
+
+
+
+
+``` + +- **`data-theme="aqua"` is mandatory** — every color/spacing token is scoped under `[data-theme="aqua"]`. Omit it and nothing styles. Dark-mode values are scoped under `html[data-mode="dark"] [data-theme="aqua"]`, so the mode attribute goes on ``. +- The providers are **required by hook-driven components** (Header, TradePanel, Orderbook, OpenOrdersSection, IndexerProgressBar, LiveBuckets, SessionMenu): `@tanstack/react-query` (QueryClientProvider), `@mysten/dapp-kit` (SuiClientProvider + WalletProvider, which itself must sit under QueryClientProvider), and `react-router-dom` (a Router). Missing any → that component throws. +- `window.Tideline.DSProvider` in this bundle is exactly this stack (dark mode + the aqua wrapper) — use it as the reference if you need a quick correct root. + +## 2. Styling idiom + +Components are **pre-styled via BEM-style classes** scoped under `[data-theme="aqua"]`, driven by `--aqua-*` CSS custom properties. **You do not add classes to library components — you compose them by passing props.** Class families you'll see (owned by the components): `header`, `panel`, `amount`, `modal`, `tile`/`tiles`, `chain`, `tradepanel`, `orderbook`, `moneyness`, `rangebar`, `cursorbar`, `tideline`, `feed`/`qrow`, `cta`, `toast`, `buy`(-grid), `bbar`, `dtabs`, `wallet`, `vault`, `dash`, `pos`. + +For **your own layout glue**, style with the design tokens via `var(--aqua-*)` — never hardcode hex: + +- Ink/text: `--aqua-ink-1` … `--aqua-ink-4` (primary → faint) +- Surfaces: `--aqua-glass`, `--aqua-glass-2`, `--aqua-glass-3`, `--aqua-solid-surface` +- Lines/dividers: `--aqua-line`, `--aqua-line-2` +- Brand: `--aqua-sui`, `--aqua-sui-deep`, `--aqua-teal`, `--aqua-accent` +- Semantic: `--aqua-success`, `--aqua-coral`, `--aqua-up`, `--aqua-down` +- Strike/moneyness tier ramp: `--aqua-t0` (hot/ITM) … `--aqua-t5` (cool/OTM) +- Background gradient stops: `--aqua-bg-top` / `--aqua-bg-mid` / `--aqua-bg-bot` + +Fonts: **Sora** (display, UI), **JetBrains Mono** (numbers, tickers, labels). + +## 3. Where the truth lives + +- `_ds_bundle.css` (imported by `styles.css`) is the **full Aqua stylesheet** — every class and `--aqua-*` token is defined there. Read it before writing any styling. +- Each component's API is its `components///.d.ts`; usage notes are in the sibling `.prompt.md`. + +## 4. Idiomatic example + +```tsx +import { StrikeTiles, AmountInput } from "tideline-frontend"; + +// inside the data-theme="aqua" + provider root from §1 +
+ + +
+``` + +Component data (strikes, buckets, positions, quotes) follows the shapes in each component's `.d.ts`. Many components also read live data through the providers above; pass realistic props for static composition. diff --git a/frontend/.design-sync/ds-provider.tsx b/frontend/.design-sync/ds-provider.tsx new file mode 100644 index 00000000..95ee3de9 --- /dev/null +++ b/frontend/.design-sync/ds-provider.tsx @@ -0,0 +1,70 @@ +// design-sync preview provider. +// +// The Tideline components assume the app's runtime context (src/main.tsx): +// react-query, dapp-kit's Sui client + wallet, a router, and the `data-theme="aqua"` +// wrapper that App.tsx puts around everything (aqua.css scopes every token under +// `[data-theme="aqua"]`, and dark-mode overrides under `html[data-mode="dark"] [data-theme="aqua"]`). +// Without this stack, hook-driven components throw ("No QueryClient", "WalletContext", +// "useNavigate outside Router") and nothing picks up aqua tokens. +// +// Wired in via cfg.provider + cfg.extraEntries so every preview card is wrapped here. +// Networked queries simply stay in their loading/empty state (retry off, no fetch in +// headless) — authored previews pass real data as props instead of relying on fetches. +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SuiClientProvider, WalletProvider, createNetworkConfig } from "@mysten/dapp-kit"; +import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; +import { MemoryRouter } from "react-router-dom"; + +// Render previews in the app's default "dark" mode (theme.ts falls back to dark; +// aqua.css scopes its dark palette under html[data-mode="dark"]). Set at module +// load AND in a layout effect so it's deterministic regardless of whether +// theme.ts is in the bundle or which module evaluates last. +function applyDarkMode() { + if (typeof document !== "undefined") { + document.documentElement.setAttribute("data-mode", "dark"); + } +} +applyDarkMode(); + +const { networkConfig } = createNetworkConfig({ + testnet: { url: getJsonRpcFullnodeUrl("testnet") }, +}); + +// retry:false so a failed/absent fetch resolves immediately to an error state +// rather than spinning; previews never depend on a live network. +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, gcTime: Infinity } }, +}); + +export function DSProvider({ children }: { children?: React.ReactNode }) { + React.useLayoutEffect(applyDarkMode, []); + return ( + + + + + {/* data-theme="aqua" paints the gradient canvas + ink color. The + explicit height/padding keeps short leaf components from + collapsing the canvas to nothing, and `transform` establishes a + containing block so the app's position:fixed/sticky bits (toasts, + header, modal overlays) stay inside the preview card instead of + escaping to the viewport. */} +
+ {children} +
+
+
+
+
+ ); +} diff --git a/frontend/.design-sync/fonts/JetBrainsMono-400-latin-ext.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-400-latin-ext.woff2 new file mode 100644 index 00000000..310faddf Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-400-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-400-latin.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-400-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-400-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-500-latin-ext.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-500-latin-ext.woff2 new file mode 100644 index 00000000..310faddf Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-500-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-500-latin.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-500-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-500-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-600-latin-ext.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-600-latin-ext.woff2 new file mode 100644 index 00000000..310faddf Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-600-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-600-latin.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-600-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-600-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-700-latin-ext.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-700-latin-ext.woff2 new file mode 100644 index 00000000..310faddf Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-700-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/JetBrainsMono-700-latin.woff2 b/frontend/.design-sync/fonts/JetBrainsMono-700-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontend/.design-sync/fonts/JetBrainsMono-700-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-300-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-300-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-300-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-300-latin.woff2 b/frontend/.design-sync/fonts/Sora-300-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-300-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-400-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-400-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-400-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-400-latin.woff2 b/frontend/.design-sync/fonts/Sora-400-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-400-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-500-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-500-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-500-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-500-latin.woff2 b/frontend/.design-sync/fonts/Sora-500-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-500-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-600-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-600-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-600-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-600-latin.woff2 b/frontend/.design-sync/fonts/Sora-600-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-600-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-700-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-700-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-700-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-700-latin.woff2 b/frontend/.design-sync/fonts/Sora-700-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-700-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-800-latin-ext.woff2 b/frontend/.design-sync/fonts/Sora-800-latin-ext.woff2 new file mode 100644 index 00000000..049d0bb1 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-800-latin-ext.woff2 differ diff --git a/frontend/.design-sync/fonts/Sora-800-latin.woff2 b/frontend/.design-sync/fonts/Sora-800-latin.woff2 new file mode 100644 index 00000000..6a4dd107 Binary files /dev/null and b/frontend/.design-sync/fonts/Sora-800-latin.woff2 differ diff --git a/frontend/.design-sync/fonts/fonts.css b/frontend/.design-sync/fonts/fonts.css new file mode 100644 index 00000000..869fdaab --- /dev/null +++ b/frontend/.design-sync/fonts/fonts.css @@ -0,0 +1,199 @@ +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./JetBrainsMono-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./JetBrainsMono-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(./JetBrainsMono-600-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(./JetBrainsMono-600-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./JetBrainsMono-700-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./JetBrainsMono-700-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(./Sora-300-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(./Sora-300-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Sora-400-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./Sora-400-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Sora-500-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./Sora-500-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(./Sora-600-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(./Sora-600-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./Sora-700-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./Sora-700-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin-ext */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(./Sora-800-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Sora'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(./Sora-800-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/frontend/.design-sync/previews/ActionModal.tsx b/frontend/.design-sync/previews/ActionModal.tsx new file mode 100644 index 00000000..e4a0aa30 --- /dev/null +++ b/frontend/.design-sync/previews/ActionModal.tsx @@ -0,0 +1,101 @@ +import { ActionModal } from "tideline-frontend"; +import type { OwnedPosition, WrittenPosition } from "../../src/types"; + +// Modal overlay (scrim + centered panel). The transform wrapper keeps the +// fixed overlay in-card. Variants cover both kinds and each meaningful stage. +// The scrim is position:fixed; this in-flow spacer grows the provider's +// transform wrapper so the (tall) modal isn't clipped vertically. +const Frame = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const owned: OwnedPosition = { + id: "0xowned_btc_1", + side: "owned", + asset: "BTC", + strike: 96000, + expiry: "2026-07-31", + amount: 0.05, + premiumPaid: 182.4, + boughtFrom: "0x7a3f…9c21", + boughtAt: "2026-06-12", + rangeId: "0x4e1b…77d0", + tradingAccountAmount: 0, + lots: [{ amount: 0.05, cost: 182.4, source: "rfq", acquiredAtMs: 1718150400000 }], + spot: 103250, + dte: 41, + itm: true, + moneyness: 7.55, + intrinsicNow: 362.5, + pnl: 180.1, + realizedPnl: 0, + totalPnl: 180.1, + unpricedExerciseAmount: 0, + status: "exercisable", +}; + +const written: WrittenPosition = { + id: "0xwritten_sui_1", + side: "written", + asset: "SUI", + strike: 4.0, + expiry: "2026-06-13", + amount: 2000, + premiumReceived: 168.0, + soldTo: "0x3f90…ab7c", + soldAt: "2026-05-30", + rangeStart: 8.4, + rangeEnd: 8.6, + cursorAtSale: 8.35, + cursorAtExpiry: 8.53, + spot: 4.12, + dte: -7, + exercisedQty: 1300, + totalQty: 2000, + exercisedPct: 65, + cursor: 8.53, + status: "claimable", +}; + +const spots = { BTC: 103250, SUI: 4.12, USDC: 1 }; + +// Exercise review: full breakdown list (strike paid, asset received, intrinsic, +// net P/L) with cancel/confirm actions. +export const ExerciseReview = () => ( + {}} + onClose={() => {}} + /> +); + +// Exercise success state: check glyph + paid/received/P-L recap + Done. +export const ExerciseConfirmed = () => ( + {}} + onClose={() => {}} + /> +); + +// Claim review: writer settlement breakdown (USDC from exercise, asset returned). +export const ClaimReview = () => ( + {}} + onClose={() => {}} + /> +); + +// In-flight signing state: spinner + waiting-for-wallet copy. +export const Signing = () => ( + {}} + onClose={() => {}} + /> +); diff --git a/frontend/.design-sync/previews/AmountInput.tsx b/frontend/.design-sync/previews/AmountInput.tsx new file mode 100644 index 00000000..ab78d9e9 --- /dev/null +++ b/frontend/.design-sync/previews/AmountInput.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { AmountInput } from "tideline-frontend"; + +// Quantity field with a custom stepper, Max button, and the asset disc. +// (Token logos come from the live token-info catalog, which is absent in the +// preview, so the disc shows the asset's initial glyph — the real fallback.) + +// Writer side: enter the underlying quantity; balance shown in the underlying. +export const Writer = () => { + const [amount, setAmount] = useState(0.05); + return ( + + ); +}; + +// Trader side: the asset⇄USDC denomination toggle is enabled (spot > 0). +export const Trader = () => { + const [amount, setAmount] = useState(0.1); + return ( + + ); +}; + +// Validation state: the error line under the field. +export const InsufficientBalance = () => { + const [amount, setAmount] = useState(0.5); + return ( + + ); +}; diff --git a/frontend/.design-sync/previews/BucketBar.tsx b/frontend/.design-sync/previews/BucketBar.tsx new file mode 100644 index 00000000..ea30809f --- /dev/null +++ b/frontend/.design-sync/previews/BucketBar.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { BucketBar } from "tideline-frontend"; + +// The top selector rail: asset · instrument · expiry · settlement · live spot. +// The spot price comes from a live Pyth SSE feed. In the preview the `symbol` +// prop is left null so no Hermes EventSource is opened (it would hold the page +// open forever); the price cell then shows its real "connecting…" placeholder, +// while the asset/expiry/settlement pickers render their selected values. + +const assets = [ + { symbol: "TBTC", decimals: 8 }, + { symbol: "SUI", decimals: 9 }, + { symbol: "TETH", decimals: 8 }, +]; + +const expiries = [ + { ms: Date.parse("2026-06-26T08:00:00Z"), iso: "2026-06-26T08:00:00Z" }, + { ms: Date.parse("2026-07-31T08:00:00Z"), iso: "2026-07-31T08:00:00Z" }, + { ms: Date.parse("2026-09-25T08:00:00Z"), iso: "2026-09-25T08:00:00Z" }, +]; + +export const BtcSeries = () => { + const [asset, setAsset] = useState("TBTC"); + const [expiry, setExpiry] = useState(expiries[0].ms); + return ( + + ); +}; + +export const SuiSeries = () => { + const [asset, setAsset] = useState("SUI"); + const [expiry, setExpiry] = useState(expiries[1].ms); + return ( + + ); +}; diff --git a/frontend/.design-sync/previews/BuyDetailTabs.tsx b/frontend/.design-sync/previews/BuyDetailTabs.tsx new file mode 100644 index 00000000..c15e12ed --- /dev/null +++ b/frontend/.design-sync/previews/BuyDetailTabs.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { BuyDetailTabs } from "tideline-frontend"; +import type { DetailTab } from "tideline-frontend"; + +// The Greeks · Details · Book · Orders card under the strike chain. Greeks and +// the marks come from /options/metrics (absent in preview → real WaveLoader / +// "—" placeholders). The segmented pill slides between the four tabs. + +const expiryMs = Date.parse("2026-06-26T08:00:00Z"); + +const series = { + asset_symbol: "TBTC", + asset_decimals: 8, + asset_coin_type: "0xtbtc::tbtc::TBTC", + settlement_symbol: "USDC", + settlement_decimals: 6, + settlement_coin_type: "0xusdc::usdc::USDC", + expiry_ms: expiryMs, + expiry_iso: "2026-06-26T08:00:00Z", + buckets: [], +}; + +const bucket = { + bucket_id: "0xbucket96k", + strike: 96000, + strike_raw: "96000000000", + call_coin_type: "0xcall::call::CALL", + strike_scale: 6, + total_written: 12.5, + total_written_raw: "1250000000", + exercise_cursor: 4.2, + exercise_cursor_raw: "420000000", + fill_pct: 33, + invalidated: false, + deepbook_pool_id: "0xpool96k", + tradeable: true, +}; + +export const Greeks = () => { + const [tab, setTab] = useState("greeks"); + return ( + + ); +}; + +export const Details = () => { + const [tab, setTab] = useState("details"); + return ( + + ); +}; + +export const Orders = () => { + const [tab, setTab] = useState("orders"); + return ( + + ); +}; diff --git a/frontend/.design-sync/previews/BuyModeToggle.tsx b/frontend/.design-sync/previews/BuyModeToggle.tsx new file mode 100644 index 00000000..498189e8 --- /dev/null +++ b/frontend/.design-sync/previews/BuyModeToggle.tsx @@ -0,0 +1,16 @@ +import { useState } from "react"; +import { BuyModeToggle } from "tideline-frontend"; +import type { BuyMode } from "tideline-frontend"; + +// Segmented control for the Buy screen's two purchase paths: trading on +// DeepBook vs. minting from the market makers. A blue pill slides between them. + +export const DeepBook = () => { + const [mode, setMode] = useState("deepbook"); + return ; +}; + +export const MarketMakers = () => { + const [mode, setMode] = useState("mm"); + return ; +}; diff --git a/frontend/.design-sync/previews/ChainTable.tsx b/frontend/.design-sync/previews/ChainTable.tsx new file mode 100644 index 00000000..cb4c2647 --- /dev/null +++ b/frontend/.design-sync/previews/ChainTable.tsx @@ -0,0 +1,68 @@ +import { ChainTable } from "tideline-frontend"; + +// Dense Deribit/Aevo-style options chain: one row per strike showing +// Bid · Mark · Ask plus model IV · Δ, with a spot-price divider threaded in. +// Per-row live book/greeks hooks are absent in preview, so bid/ask/iv/Δ render +// their real "—" placeholders while Mark falls back to the indicative premium. + +const expiryMs = Date.parse("2026-06-26T08:00:00Z"); + +const series = { + asset_symbol: "TBTC", + asset_decimals: 8, + asset_coin_type: "0xtbtc::tbtc::TBTC", + settlement_symbol: "USDC", + settlement_decimals: 6, + settlement_coin_type: "0xusdc::usdc::USDC", + expiry_ms: expiryMs, + expiry_iso: "2026-06-26T08:00:00Z", + buckets: [], +}; + +const STRIKES = [88000, 92000, 96000, 100000, 104000, 108000]; +const MARKS = [9120, 6240, 3680, 1990, 940, 410]; + +const buckets = STRIKES.map((strike, i) => ({ + bucket_id: `0xbucket${i}`, + strike, + strike_raw: String(strike * 1e6), + call_coin_type: `0xcall::call::CALL${i}`, + strike_scale: 6, + total_written: 12.5, + total_written_raw: "1250000000", + exercise_cursor: 4.2, + exercise_cursor_raw: "420000000", + fill_pct: 33, + invalidated: false, + deepbook_pool_id: `0xpool${i}`, + tradeable: true, +})); + +const strikes = STRIKES.map((strike, i) => ({ + strike, + perUnit: MARKS[i] / strike, + premium: MARKS[i], + premiumDisplay: MARKS[i].toFixed(2), +})); + +export const FullChain = () => ( + {}} + /> +); + +export const DeepInTheMoney = () => ( + {}} + /> +); diff --git a/frontend/.design-sync/previews/ChartPanel.tsx b/frontend/.design-sync/previews/ChartPanel.tsx new file mode 100644 index 00000000..751c30dc --- /dev/null +++ b/frontend/.design-sync/previews/ChartPanel.tsx @@ -0,0 +1,19 @@ +import { ChartPanel } from "tideline-frontend"; + +// Price chart for a bucket's DeepBook pool. Data is fetched live via useBars +// (price-charting REST + WS). In preview there is no live service, so the +// chart frame, series/interval toggles, themed grid, and strike price-line +// render, with the "No quotes yet" empty hint below. Sized box so the +// lightweight-charts canvas has height in headless capture. + +// One cell only: without a live price-charting service both pool ids render +// the identical no-data state, so a second variant would just duplicate it. +export const MarketPanel = () => ( +
+ +
+); diff --git a/frontend/.design-sync/previews/ConfirmModal.tsx b/frontend/.design-sync/previews/ConfirmModal.tsx new file mode 100644 index 00000000..470912f3 --- /dev/null +++ b/frontend/.design-sync/previews/ConfirmModal.tsx @@ -0,0 +1,59 @@ +import { ConfirmModal } from "tideline-frontend"; +import type { ConfirmSummary } from "../../src/types"; + +// Post-trade confirmation overlay from the Composer. Spinner stages have no +// summary; the confirmed stage renders a per-view breakdown list. +// The modal scrim is position:fixed; the preview provider's transform wrapper +// makes "fixed" relative to itself, so this in-flow spacer gives the scrim +// (and the centered modal) full height instead of collapsing. +const Frame = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const writerSummary: ConfirmSummary = { + view: "writer", + premium: 314.2, + bucket: "btc_100000_20260731", + rangeStart: 1.2, + rangeEnd: 1.3, + amount: 0.1, + strike: 100000, + asset: "BTC", + expiry: "2026-07-31", +}; + +const traderSummary: ConfirmSummary = { + view: "trader", + premium: 199.0, + bucket: "btc_100000_20260731", + rangeStart: 1.2, + rangeEnd: 1.3, + amount: 0.05, + strike: 100000, + asset: "BTC", + expiry: "2026-07-31", +}; + +// Wallet signing spinner stage. +export const Signing = () => ( + {}} /> +); + +// On-chain broadcast spinner stage. +export const Broadcast = () => ( + {}} /> +); + +// Writer success: premium received / range / collateral locked. +export const WriterConfirmed = () => ( + + {}} /> + +); + +// Trader success: call options minted / premium paid / expiry. +export const TraderConfirmed = () => ( + + {}} /> + +); diff --git a/frontend/.design-sync/previews/ConnectMenu.tsx b/frontend/.design-sync/previews/ConnectMenu.tsx new file mode 100644 index 00000000..3c31af4c --- /dev/null +++ b/frontend/.design-sync/previews/ConnectMenu.tsx @@ -0,0 +1,10 @@ +import { ConnectMenu } from "tideline-frontend"; + +// The wallet-connect affordance shown while disconnected. When a session-login +// deployment is configured it's a dropdown trigger that fans out into Sui +// wallet + "Sign in with Phantom / MetaMask / WalletConnect" options; with no +// session deployment (the preview default) it collapses to the real plain +// "Connect wallet" pill. Either way this is the genuine disconnected state — +// the dropdown only mounts on click, so the resting trigger is what shows here. + +export const Disconnected = () => {}} />; diff --git a/frontend/.design-sync/previews/Header.tsx b/frontend/.design-sync/previews/Header.tsx new file mode 100644 index 00000000..4a4329ec --- /dev/null +++ b/frontend/.design-sync/previews/Header.tsx @@ -0,0 +1,10 @@ +import { Header } from "tideline-frontend"; + +// The top nav bar: tideline wordmark + animated wave backdrop, the route pill +// nav (Earn · Buy · Vaults · Dashboard · Activity · Github), gas-sponsor and +// theme toggles, and the wallet affordance. No wallet is connected in preview, +// so the right side renders its real disconnected "Connect wallet" control +// (a ConnectMenu / plain connect button depending on session-login availability). +// Header is full-width by design — see learnings for the cardMode:"single" ask. + +export const Disconnected = () =>
; diff --git a/frontend/.design-sync/previews/IndexerProgressBar.tsx b/frontend/.design-sync/previews/IndexerProgressBar.tsx new file mode 100644 index 00000000..8f1c8d71 --- /dev/null +++ b/frontend/.design-sync/previews/IndexerProgressBar.tsx @@ -0,0 +1,9 @@ +import { IndexerProgressBar } from "tideline-frontend"; + +// Indexer checkpoint-ingestion progress. Fetches GET /indexer/progress via +// react-query (useIndexerProgress) — no props. With no api-service in preview +// the fetch fails and the component shows its "indexer status unavailable" +// floor card; with a live backend it shows the filled bar + checkpoint/rate/ +// eta stats and a "live" pill once caught up. + +export const Status = () => ; diff --git a/frontend/.design-sync/previews/LiveBuckets.tsx b/frontend/.design-sync/previews/LiveBuckets.tsx new file mode 100644 index 00000000..3495c3c1 --- /dev/null +++ b/frontend/.design-sync/previews/LiveBuckets.tsx @@ -0,0 +1,9 @@ +import { LiveBuckets } from "tideline-frontend"; + +// Live view of on-chain bucket series. Fetches GET /buckets via react-query +// (useBuckets) — no props. With no api-service in preview the fetch fails and +// the section shows its title plus an error/empty status line; with a live +// backend each series renders as a collapsible
with a strike/written/ +// exercised/fill/bucket-id table. + +export const Buckets = () => ; diff --git a/frontend/.design-sync/previews/OpenOrdersSection.tsx b/frontend/.design-sync/previews/OpenOrdersSection.tsx new file mode 100644 index 00000000..7642a5c9 --- /dev/null +++ b/frontend/.design-sync/previews/OpenOrdersSection.tsx @@ -0,0 +1,38 @@ +import { OpenOrdersSection } from "tideline-frontend"; + +// Open DeepBook orders for a bucket's pool, the "orders" tab of BuyDetailTabs: +// a header with the live count + cancel-all/withdraw controls, then a +// side/options/price table with per-row cancel. It owns the BalanceManager read, +// which is gated on a connected wallet; with none present it renders its real +// pre-trade prompt: "enable trading to see open orders". (The populated order +// table is data-gated on a connected wallet with resting DeepBook orders.) + +const series = { + asset_symbol: "TBTC", + asset_decimals: 8, + asset_coin_type: "0xtbtc::tbtc::TBTC", + settlement_symbol: "USDC", + settlement_decimals: 6, + settlement_coin_type: "0xusdc::usdc::USDC", + expiry_ms: Date.parse("2026-06-26T08:00:00Z"), + expiry_iso: "2026-06-26T08:00:00Z", + buckets: [], +}; + +const bucket = { + bucket_id: "0xbucket", + strike: 96000, + strike_raw: "96000000000", + call_coin_type: "0xcall::call::CALL", + strike_scale: 6, + total_written: 12.5, + total_written_raw: "1250000000", + exercise_cursor: 4.2, + exercise_cursor_raw: "420000000", + fill_pct: 33, + invalidated: false, + deepbook_pool_id: "0xpool", + tradeable: true, +}; + +export const EnablePrompt = () => ; diff --git a/frontend/.design-sync/previews/Orderbook.tsx b/frontend/.design-sync/previews/Orderbook.tsx new file mode 100644 index 00000000..affc9089 --- /dev/null +++ b/frontend/.design-sync/previews/Orderbook.tsx @@ -0,0 +1,36 @@ +import { Orderbook } from "tideline-frontend"; + +// Standalone DeepBook order book: "order book" heading with the top-of-book +// "mid · —" label, then ask rows (red) above the mid divider and bid rows +// (teal) below. The book is fetched live per pool; absent live DeepBook data in +// preview it renders its real empty state ("book is empty") with a "—" mid. + +const series = { + asset_symbol: "TBTC", + asset_decimals: 8, + asset_coin_type: "0xtbtc::tbtc::TBTC", + settlement_symbol: "USDC", + settlement_decimals: 6, + settlement_coin_type: "0xusdc::usdc::USDC", + expiry_ms: Date.parse("2026-06-26T08:00:00Z"), + expiry_iso: "2026-06-26T08:00:00Z", + buckets: [], +}; + +const bucket = { + bucket_id: "0xbucket", + strike: 96000, + strike_raw: "96000000000", + call_coin_type: "0xcall::call::CALL", + strike_scale: 6, + total_written: 12.5, + total_written_raw: "1250000000", + exercise_cursor: 4.2, + exercise_cursor_raw: "420000000", + fill_pct: 33, + invalidated: false, + deepbook_pool_id: "0xpool", + tradeable: true, +}; + +export const EmptyBook = () => ; diff --git a/frontend/.design-sync/previews/OwnedCard.tsx b/frontend/.design-sync/previews/OwnedCard.tsx new file mode 100644 index 00000000..64abeb8e --- /dev/null +++ b/frontend/.design-sync/previews/OwnedCard.tsx @@ -0,0 +1,101 @@ +import { OwnedCard } from "tideline-frontend"; +import type { OwnedPosition } from "../../src/types"; + +// A fully-decorated owned (long call) position. Built across the `status` enum +// so the footer CTA + moneyness bar render their distinct states. + +const base: OwnedPosition = { + id: "0xowned_btc_1", + side: "owned", + asset: "BTC", + strike: 96000, + expiry: "2026-07-31", + amount: 0.05, + premiumPaid: 182.4, + boughtFrom: "0x7a3f…9c21", + boughtAt: "2026-06-12", + rangeId: "0x4e1b…77d0", + tradingAccountAmount: 0, + lots: [ + { amount: 0.03, cost: 109.4, source: "rfq", acquiredAtMs: 1718150400000 }, + { amount: 0.02, cost: 73.0, source: "deepbook", acquiredAtMs: 1718323200000 }, + ], + spot: 103250, + dte: 41, + itm: true, + moneyness: 7.55, + intrinsicNow: 362.5, + pnl: 180.1, + realizedPnl: 0, + totalPnl: 180.1, + unpricedExerciseAmount: 0, + status: "exercisable", +}; + +// Spot above strike, in-the-money, exercise CTA live. Has a DeepBook +// trading-account slice + realized PnL row to exercise the dense layout. +export const Exercisable = () => ( + {}} + onWithdraw={() => {}} + /> +); + +// SUI call sitting out of the money — hold + "exercise anyway" ghost/secondary CTAs. +export const ActiveOtm = () => ( + {}} + onWithdraw={() => {}} + /> +); + +// Expired while in the money — exercise window closed, disabled ghost CTA. +export const ExpiredItm = () => ( + {}} + onWithdraw={() => {}} + /> +); diff --git a/frontend/.design-sync/previews/PayoffChart.tsx b/frontend/.design-sync/previews/PayoffChart.tsx new file mode 100644 index 00000000..6c3cebf7 --- /dev/null +++ b/frontend/.design-sync/previews/PayoffChart.tsx @@ -0,0 +1,25 @@ +import { PayoffChart } from "tideline-frontend"; + +// Long-call payoff at expiry. pnl(S) = max(0, S − strike)·qty − totalCost. +// Pure props, no fetching. breakEven = strike + avgPrice (per-unit cost). + +// 0.5 TBTC long call, strike 96000, premium ~340 USDC/BTC → totalCost 170. +export const InTheMoney = () => ( +
+ +
+); + +// Spot sitting below strike — flat loss leg, dot on the floor. +export const OutOfTheMoney = () => ( +
+ +
+); + +// Larger position: 2 TBTC, deeper premium, spot right at break-even. +export const AtBreakEven = () => ( +
+ +
+); diff --git a/frontend/.design-sync/previews/QuoteFeed.tsx b/frontend/.design-sync/previews/QuoteFeed.tsx new file mode 100644 index 00000000..7caf15f3 --- /dev/null +++ b/frontend/.design-sync/previews/QuoteFeed.tsx @@ -0,0 +1,24 @@ +import { QuoteFeed } from "tideline-frontend"; + +// Live RFQ feed from the market makers. Trader view ranks asks low→high; +// writer view ranks bids high→low. The first row is highlighted as best. + +const now = Date.now(); +const quotes = [ + { id: "q1", name: "Aftermath MM", addr: "0x9f3a…21bc", fill: 99, revertRate: 0.4, latency: 38, premium: 3640, ttl: 12, arrivedAt: now }, + { id: "q2", name: "Cetus Desk", addr: "0x1c08…7de2", fill: 97, revertRate: 0.9, latency: 52, premium: 3685, ttl: 9, arrivedAt: now }, + { id: "q3", name: "Kriya Quotes", addr: "0xab8d…0f41", fill: 95, revertRate: 1.6, latency: 71, premium: 3720, ttl: 7, arrivedAt: now }, + { id: "q4", name: "Bluefin OTC", addr: "0xf2cb…9a55", fill: 92, revertRate: 2.3, latency: 96, premium: 3805, ttl: 5, arrivedAt: now }, +]; + +export const TraderAsks = () => ( + +); + +export const WriterBids = () => ( + +); + +export const Empty = () => ( + +); diff --git a/frontend/.design-sync/previews/StrikeTiles.tsx b/frontend/.design-sync/previews/StrikeTiles.tsx new file mode 100644 index 00000000..df1a6418 --- /dev/null +++ b/frontend/.design-sync/previews/StrikeTiles.tsx @@ -0,0 +1,21 @@ +import { StrikeTiles } from "tideline-frontend"; + +// One bucket strike per tile, premium under the price. The tier ink ramps +// hot→cool across the series (writer view); trader view inverts and shows the +// premium as a cost (−). +const strikes = [ + { strike: 88000, perUnit: 0.0072, premium: 684, premiumDisplay: "684.00" }, + { strike: 92000, perUnit: 0.0051, premium: 485, premiumDisplay: "485.00" }, + { strike: 96000, perUnit: 0.0034, premium: 323, premiumDisplay: "323.00" }, + { strike: 100000, perUnit: 0.0021, premium: 199, premiumDisplay: "199.00" }, + { strike: 104000, perUnit: 0.0012, premium: 114, premiumDisplay: "114.00" }, + { strike: 108000, perUnit: 0.0006, premium: 57, premiumDisplay: "57.00" }, +]; + +export const WriterView = () => ( + {}} view="writer" /> +); + +export const TraderView = () => ( + {}} view="trader" /> +); diff --git a/frontend/.design-sync/previews/Tideline.tsx b/frontend/.design-sync/previews/Tideline.tsx new file mode 100644 index 00000000..7823d081 --- /dev/null +++ b/frontend/.design-sync/previews/Tideline.tsx @@ -0,0 +1,28 @@ +import { Tideline } from "tideline-frontend"; + +// The writer's "place in the queue" visual: an animated wave bar showing the +// exercised zone, the queue ahead, and the writer's own position highlighted. + +export const Queued = () => ( + +); + +export const FrontOfQueue = () => ( + +); + +export const NearlyExercised = () => ( + +); diff --git a/frontend/.design-sync/previews/Toast.tsx b/frontend/.design-sync/previews/Toast.tsx new file mode 100644 index 00000000..9f05bc93 --- /dev/null +++ b/frontend/.design-sync/previews/Toast.tsx @@ -0,0 +1,12 @@ +import { Toast } from "tideline-frontend"; + +// Transient status pill shown after an action. One dot color per variant. +export const Success = () => ( + +); + +export const ErrorToast = () => ( + +); + +export const Info = () => ; diff --git a/frontend/.design-sync/previews/TokenLogo.tsx b/frontend/.design-sync/previews/TokenLogo.tsx new file mode 100644 index 00000000..da44bf12 --- /dev/null +++ b/frontend/.design-sync/previews/TokenLogo.tsx @@ -0,0 +1,33 @@ +import { TokenLogo } from "tideline-frontend"; + +// The token-info catalog is absent in preview, so findToken returns nothing and +// TokenLogo renders the call site's `fallback` node — the real fallback path. +// These mirror the asset glyphs used by the position cards. + +const btcGlyph = ; +const suiGlyph = ; +const usdcGlyph = U; + +// BTC ticker → bitcoin glyph fallback. +export const BtcFallback = () => ( + +); + +// SUI ticker → wave glyph fallback. +export const SuiFallback = () => ( + +); + +// Unknown symbol → initial-letter fallback. +export const InitialFallback = () => ( + +); + +// Small sizing class — the fallback respects each call site's icon dimensions. +export const SmallFallback = () => ( + ₿} + /> +); diff --git a/frontend/.design-sync/previews/TradePanel.tsx b/frontend/.design-sync/previews/TradePanel.tsx new file mode 100644 index 00000000..d49f769f --- /dev/null +++ b/frontend/.design-sync/previews/TradePanel.tsx @@ -0,0 +1,37 @@ +import { TradePanel } from "tideline-frontend"; + +// DeepBook trade ticket for a bucket's pool. The BalanceManager lookup keys off +// a connected wallet/session; with none present the query is disabled and the +// panel renders its real first-run setup card: "trade on deepbook" heading, the +// one-time BalanceManager explainer, and a "Connect to trade" CTA. (With a +// wallet it becomes the buy/sell order form + holdings — data-gated in preview.) + +const series = { + asset_symbol: "TBTC", + asset_decimals: 8, + asset_coin_type: "0xtbtc::tbtc::TBTC", + settlement_symbol: "USDC", + settlement_decimals: 6, + settlement_coin_type: "0xusdc::usdc::USDC", + expiry_ms: Date.parse("2026-06-26T08:00:00Z"), + expiry_iso: "2026-06-26T08:00:00Z", + buckets: [], +}; + +const bucket = { + bucket_id: "0xbucket", + strike: 96000, + strike_raw: "96000000000", + call_coin_type: "0xcall::call::CALL", + strike_scale: 6, + total_written: 12.5, + total_written_raw: "1250000000", + exercise_cursor: 4.2, + exercise_cursor_raw: "420000000", + fill_pct: 33, + invalidated: false, + deepbook_pool_id: "0xpool", + tradeable: true, +}; + +export const SetupState = () => ; diff --git a/frontend/.design-sync/previews/TraderPanels.tsx b/frontend/.design-sync/previews/TraderPanels.tsx new file mode 100644 index 00000000..313602f1 --- /dev/null +++ b/frontend/.design-sync/previews/TraderPanels.tsx @@ -0,0 +1,40 @@ +import { TraderPanels } from "tideline-frontend"; + +// The trader-side economics panels: premium paid now, the exercise pay/receive +// split, and the breakeven · max-loss · upside scenario row. + +export const Call = () => ( + +); + +export const Loading = () => ( + +); + +export const SuiCall = () => ( + +); diff --git a/frontend/.design-sync/previews/VaultApyChart.tsx b/frontend/.design-sync/previews/VaultApyChart.tsx new file mode 100644 index 00000000..753c3095 --- /dev/null +++ b/frontend/.design-sync/previews/VaultApyChart.tsx @@ -0,0 +1,51 @@ +import { VaultApyChart } from "tideline-frontend"; + +// APY-over-time: solid green realized line (finalized rounds) + dashed accent +// projected line anchored to the last realized point, with a confidence band +// and the per-round assignment-risk footer. apy is a fraction (0.18 = 18%). + +const DAY = 86_400_000; +const t0 = Date.parse("2026-04-01T00:00:00Z"); + +const realized = [ + { t_ms: t0 + 0 * 7 * DAY, apy: 0.142 }, + { t_ms: t0 + 1 * 7 * DAY, apy: 0.168 }, + { t_ms: t0 + 2 * 7 * DAY, apy: 0.155 }, + { t_ms: t0 + 3 * 7 * DAY, apy: 0.191 }, + { t_ms: t0 + 4 * 7 * DAY, apy: 0.173 }, + { t_ms: t0 + 5 * 7 * DAY, apy: 0.205 }, + { t_ms: t0 + 6 * 7 * DAY, apy: 0.188 }, +]; + +const predicted = [ + { + t_ms: t0 + 7 * 7 * DAY, + apy: 0.196, + apy_low: 0.12, + apy_high: 0.27, + kind: "current", + confidence: 0.7, + assignment_prob: 0.31, + downside_round_yield: -0.018, + }, +]; + +export const RealizedAndProjected = () => ( +
+ +
+); + +// Several finalized rounds, no forecast deployed yet → just the realized curve. +export const RealizedOnly = () => ( +
+ +
+); + +// Fresh vault, no rounds settled — the "coming soon" empty state. +export const Empty = () => ( +
+ +
+); diff --git a/frontend/.design-sync/previews/WaveLoader.tsx b/frontend/.design-sync/previews/WaveLoader.tsx new file mode 100644 index 00000000..3e5d7d62 --- /dev/null +++ b/frontend/.design-sync/previews/WaveLoader.tsx @@ -0,0 +1,17 @@ +import { WaveLoader } from "tideline-frontend"; + +// Cresting-wave line shown in place of the hero premium while a firm RFQ +// quote is in flight. Pure presentational SVG; spans its container width. + +export const Default = () => ( +
+ +
+); + +// In context: sized like the hero premium slot it replaces while quoting. +export const HeroSlot = () => ( +
+ +
+); diff --git a/frontend/.design-sync/previews/WriterPanels.tsx b/frontend/.design-sync/previews/WriterPanels.tsx new file mode 100644 index 00000000..59cd9aa4 --- /dev/null +++ b/frontend/.design-sync/previews/WriterPanels.tsx @@ -0,0 +1,37 @@ +import { WriterPanels } from "tideline-frontend"; + +// The writer-side economics panels: premium earned upfront and the on-expiry +// split (asset sold at strike if ITM vs. collateral returned if OTM). + +export const CoveredCall = () => ( + +); + +export const Loading = () => ( + +); + +export const SuiCoveredCall = () => ( + +); diff --git a/frontend/.design-sync/previews/WrittenCard.tsx b/frontend/.design-sync/previews/WrittenCard.tsx new file mode 100644 index 00000000..a48fdb7e --- /dev/null +++ b/frontend/.design-sync/previews/WrittenCard.tsx @@ -0,0 +1,81 @@ +import { WrittenCard } from "tideline-frontend"; +import type { WrittenPosition } from "../../src/types"; + +// A fully-decorated written (short call) position. Variants span the `status` +// enum so the range bar fill + footer CTA render their distinct states. + +const base: WrittenPosition = { + id: "0xwritten_btc_1", + side: "written", + asset: "BTC", + strike: 100000, + expiry: "2026-07-31", + amount: 0.1, + premiumReceived: 314.2, + soldTo: "0x6b2e…d18a", + soldAt: "2026-06-10", + rangeStart: 1.2, + rangeEnd: 1.3, + cursorAtSale: 1.18, + cursorAtExpiry: 1.3, + spot: 103250, + dte: 41, + exercisedQty: 0, + totalQty: 0.1, + exercisedPct: 0, + cursor: 1.24, + status: "active", +}; + +// Live, not yet exercised — ghost CTA + "expires in Nd" foot note, empty range fill. +export const Active = () => ( + {}} + /> +); + +// Bucket cursor has crossed partway into the writer's range. +export const PartiallyExercised = () => ( + {}} + /> +); + +// Expired, settlement claimable — primary CTA shows USD-equivalent payout. +export const Claimable = () => ( + {}} + /> +); diff --git a/frontend/.design-sync/tsconfig.dssync.json b/frontend/.design-sync/tsconfig.dssync.json new file mode 100644 index 00000000..c2cde447 --- /dev/null +++ b/frontend/.design-sync/tsconfig.dssync.json @@ -0,0 +1,10 @@ +{ + "comment": "design-sync-only tsconfig. The converter's tsconfigPathsPlugin reads paths/baseUrl from THIS file literally (it does not follow `extends`), so the alias below is the whole point: redirect the heavy WalletConnect dynamic import to a local stub to stay under the 5 MB bundle cap. The app's real build still uses ../tsconfig.json.", + "compilerOptions": { + "baseUrl": ".", + "jsx": "react-jsx", + "paths": { + "@walletconnect/ethereum-provider": ["./wc-stub.ts"] + } + } +} diff --git a/frontend/.design-sync/wc-stub.ts b/frontend/.design-sync/wc-stub.ts new file mode 100644 index 00000000..342bed2f --- /dev/null +++ b/frontend/.design-sync/wc-stub.ts @@ -0,0 +1,18 @@ +// design-sync stub for @walletconnect/ethereum-provider. +// +// The real package transitively pulls in @reown/appkit, viem, and the rest of +// the WalletConnect stack (~9 MB) — far over claude.ai/design's 5 MB bundle +// cap. It is reached only through `session/wallets.ts`'s dynamic +// `import("@walletconnect/ethereum-provider")`, which runs solely when a user +// clicks "connect WalletConnect" — never during a static design preview. +// +// Aliased in via .design-sync/tsconfig.dssync.json (cfg.tsconfig) so the +// converter's tsconfigPathsPlugin resolves the import here instead of bundling +// the real stack. Components render identically; only the live WC connection +// path is inert in previews. +export class EthereumProvider { + static async init(): Promise { + throw new Error("WalletConnect is unavailable in the design-sync preview bundle"); + } +} +export default { EthereumProvider }; diff --git a/frontend/.gitignore b/frontend/.gitignore index 79c91078..0e6f93c5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -6,3 +6,8 @@ dist .env tsconfig.tsbuildinfo .vercel +.ds-sync/ +ds-bundle/ +.design-sync/.cache/ +.design-sync/learnings/ +.design-sync/node_modules diff --git a/rust-backend/Cargo.lock b/rust-backend/Cargo.lock index 03ee40e6..cc9d445a 100644 --- a/rust-backend/Cargo.lock +++ b/rust-backend/Cargo.lock @@ -35,7 +35,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -152,6 +152,775 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-pubsub", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-transport-ipc", + "alloy-transport-ws", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096" +dependencies = [ + "alloy-primitives", + "num_enum 0.7.6", + "strum 0.27.2", +] + +[[package]] +name = "alloy-consensus" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more 2.1.1", + "either", + "k256 0.13.4", + "once_cell", + "rand 0.8.6", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac9e0c34dc6bce643b182049cdfcca1b8ce7d9c260cbdd561f511873b7e26cd" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-pubsub", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-core" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ddde5968de6044d67af107ad835bc0069a7ca245870b94c5958a7d8712b184" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a475bb02d9cef2dbb99065c1664ab3fe1f9352e21d6d5ed3f02cdbfc06ed1abc" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow 1.0.3", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "k256 0.13.4", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eips" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ef28c9fdad22d4eec52d894f5f2673a0895f1e5ef196734568e68c0f6caca8" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more 2.1.1", + "either", + "serde", + "serde_with", + "sha2 0.10.9", +] + +[[package]] +name = "alloy-genesis" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf9480307b09d22876efb67d30cadd9013134c21f3a17ec9f93fd7536d38024" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more 2.1.1", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more 2.1.1", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256 0.13.4", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.4", + "rapidhash", + "ruint", + "rustc-hash 2.1.2", + "secp256k1 0.31.1", + "serde", + "sha3 0.11.0", +] + +[[package]] +name = "alloy-provider" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6b18b929ef1d078b834c3631e9c925177f3b23ddc6fa08a722d13047205876" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-pubsub", + "alloy-rpc-client", + "alloy-rpc-types-anvil", + "alloy-rpc-types-debug", + "alloy-rpc-types-eth", + "alloy-rpc-types-trace", + "alloy-rpc-types-txpool", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "alloy-transport-ipc", + "alloy-transport-ws", + "async-stream", + "async-trait", + "auto_impl", + "dashmap 6.1.0", + "either", + "futures", + "futures-utils-wasm", + "lru 0.16.4", + "parking_lot 0.12.5", + "pin-project", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-pubsub" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad54073131e7292d4e03e1aa2287730f737280eb160d8b579fb31939f558c11" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "auto_impl", + "bimap", + "futures", + "parking_lot 0.12.5", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower 0.5.3", + "tracing", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fcc9604042ca80bd37aa5e232ea1cd851f337e31e2babbbb345bc0b1c30de3" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-pubsub", + "alloy-transport", + "alloy-transport-http", + "alloy-transport-ipc", + "alloy-transport-ws", + "futures", + "pin-project", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower 0.5.3", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-anvil", + "alloy-rpc-types-debug", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "alloy-rpc-types-trace", + "alloy-rpc-types-txpool", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-anvil" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47df51bedb3e6062cb9981187a51e86d0d64a4de66eb0855e9efe6574b044ddf" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-debug" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2145138f3214928f08cd13da3cb51ef7482b5920d8ac5a02ecd4e38d1a8f6d1e" +dependencies = [ + "alloy-primitives", + "derive_more 2.1.1", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-rpc-types-engine" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9b97b6e7965679ad22df297dda809b11cebc13405c1b537e5cffecc95834fa" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "derive_more 2.1.1", + "rand 0.8.6", + "serde", + "strum 0.27.2", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-rpc-types-trace" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a4d010f86cd4e01e5205ec273911e538e1738e76d8bafe9ecd245910ea5a3" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-rpc-types-txpool" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942d26a2ca8891b26de4a8529d21091e21c1093e27eb99698f1a86405c76b1ff" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-serde" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f447aefab0f1c0649f71edc33f590992d4e122bc35fb9cdbbf67d4421ace85" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve 0.13.8", + "k256 0.13.4", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-signer-local" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256 0.13.4", + "rand 0.8.6", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck 0.5.0", + "indexmap 2.14.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "sha3 0.11.0", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck 0.5.0", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +dependencies = [ + "serde", + "winnow 1.0.3", +] + +[[package]] +name = "alloy-sol-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64 0.22.1", + "derive_more 2.1.1", + "futures", + "futures-utils-wasm", + "parking_lot 0.12.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower 0.5.3", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest 0.13.4", + "serde_json", + "tower 0.5.3", + "tracing", + "url", +] + +[[package]] +name = "alloy-transport-ipc" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1bd98c3870b8a44b79091dde5216a81d58ffbc1fd8ed61b776f9fee0f3bdf20" +dependencies = [ + "alloy-json-rpc", + "alloy-pubsub", + "alloy-transport", + "bytes", + "futures", + "interprocess", + "pin-project", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "alloy-transport-ws" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3ab7a72b180992881acc112628b7668337a19ce15293ee974600ea7b693691" +dependencies = [ + "alloy-pubsub", + "alloy-transport", + "futures", + "http", + "rustls", + "serde_json", + "tokio", + "tokio-tungstenite 0.28.0", + "tracing", + "url", + "ws_stream_wasm", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more 2.1.1", + "nybbles", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -322,7 +1091,7 @@ dependencies = [ "observability", "pricing", "protocol-types", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -342,7 +1111,7 @@ dependencies = [ "observability", "parking_lot 0.12.5", "protocol-types", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tracing", @@ -370,8 +1139,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" dependencies = [ "ark-ec", - "ark-ff", - "ark-std", + "ark-ff 0.4.2", + "ark-std 0.4.0", ] [[package]] @@ -381,11 +1150,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3a13b34da09176a8baba701233fdffbaa7c1b1192ce031a3da4e55ce1f1a56" dependencies = [ "ark-ec", - "ark-ff", + "ark-ff 0.4.2", "ark-relations", - "ark-serialize", + "ark-serialize 0.4.2", "ark-snark", - "ark-std", + "ark-std 0.4.0", "blake2", "derivative", "digest 0.10.7", @@ -398,10 +1167,10 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" dependencies = [ - "ark-ff", + "ark-ff 0.4.2", "ark-poly", - "ark-serialize", - "ark-std", + "ark-serialize 0.4.2", + "ark-std 0.4.0", "derivative", "hashbrown 0.13.2", "itertools 0.10.5", @@ -409,26 +1178,74 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint 0.4.6", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + [[package]] name = "ark-ff" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" dependencies = [ - "ark-ff-asm", - "ark-ff-macros", - "ark-serialize", - "ark-std", + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", "derivative", "digest 0.10.7", "itertools 0.10.5", "num-bigint 0.4.6", "num-traits", "paste", - "rustc_version", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-traits", + "paste", "zeroize", ] +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "ark-ff-asm" version = "0.4.2" @@ -439,6 +1256,28 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "quote", + "syn 1.0.109", +] + [[package]] name = "ark-ff-macros" version = "0.4.2" @@ -449,7 +1288,20 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 1.0.109", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -460,11 +1312,11 @@ checksum = "20ceafa83848c3e390f1cbf124bc3193b3e639b3f02009e0e290809a501b95fc" dependencies = [ "ark-crypto-primitives", "ark-ec", - "ark-ff", + "ark-ff 0.4.2", "ark-poly", "ark-relations", - "ark-serialize", - "ark-std", + "ark-serialize 0.4.2", + "ark-std 0.4.0", ] [[package]] @@ -473,9 +1325,9 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" dependencies = [ - "ark-ff", - "ark-serialize", - "ark-std", + "ark-ff 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", "derivative", "hashbrown 0.13.2", ] @@ -486,8 +1338,8 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00796b6efc05a3f48225e59cb6a2cda78881e7c390872d5786aaf112f31fb4f0" dependencies = [ - "ark-ff", - "ark-std", + "ark-ff 0.4.2", + "ark-std 0.4.0", "tracing", ] @@ -498,8 +1350,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c02e954eaeb4ddb29613fee20840c2bbc85ca4396d53e33837e11905363c5f2" dependencies = [ "ark-ec", - "ark-ff", - "ark-std", + "ark-ff 0.4.2", + "ark-std 0.4.0", ] [[package]] @@ -509,8 +1361,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3975a01b0a6e3eae0f72ec7ca8598a6620fc72fa5981f6f5cca33b7cd788f633" dependencies = [ "ark-ec", - "ark-ff", - "ark-std", + "ark-ff 0.4.2", + "ark-std 0.4.0", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", ] [[package]] @@ -520,7 +1382,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", - "ark-std", + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", "digest 0.10.7", "num-bigint 0.4.6", ] @@ -542,10 +1416,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d3cc6833a335bb8a600241889ead68ee89a3cf8448081fb7694c0fe503da63" dependencies = [ - "ark-ff", + "ark-ff 0.4.2", "ark-relations", - "ark-serialize", - "ark-std", + "ark-serialize 0.4.2", + "ark-std 0.4.0", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.6", ] [[package]] @@ -558,6 +1442,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -784,6 +1678,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version 0.4.1", +] + [[package]] name = "asynk-strim" version = "0.1.5" @@ -807,7 +1712,7 @@ dependencies = [ "anyhow", "axum 0.7.9", "observability", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 1.0.69", @@ -845,6 +1750,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "auto_ops" version = "0.3.0" @@ -857,6 +1773,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -1243,6 +2181,33 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -1258,6 +2223,16 @@ dependencies = [ "bitcoin-private", ] +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1356,6 +2331,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.2.1" @@ -1405,6 +2389,102 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases 0.2.1", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bridge-relayer" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "async-trait", + "bridge-signer", + "bridge-types", + "clap", + "config", + "hex", + "observability", + "reqwest 0.12.28", + "runtime-config", + "serde", + "serde_json", + "sui-json-rpc-types", + "sui-sdk", + "sui-tx", + "sui-types", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "bridge-signer" +version = "0.1.0" +dependencies = [ + "bridge-types", + "ed25519-dalek", + "hex", + "k256 0.13.4", + "thiserror 1.0.69", +] + +[[package]] +name = "bridge-signer-service" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.7.9", + "bridge-signer", + "bridge-types", + "clap", + "config", + "hex", + "observability", + "reqwest 0.12.28", + "runtime-config", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tower 0.5.3", + "tower-http 0.6.10", + "tracing", +] + +[[package]] +name = "bridge-types" +version = "0.1.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tiny-keccak", +] + [[package]] name = "brotli" version = "8.0.2" @@ -1513,6 +2593,21 @@ dependencies = [ "bytes", ] +[[package]] +name = "c-kzg" +version = "2.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -1640,7 +2735,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1721,6 +2816,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmp_any" version = "0.8.1" @@ -1870,6 +2974,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1908,6 +3024,27 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1957,6 +3094,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2001,6 +3147,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -2111,6 +3272,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -2164,7 +3334,7 @@ dependencies = [ "fiat-crypto", "group 0.13.0", "rand_core 0.6.4", - "rustc_version", + "rustc_version 0.4.1", "serde", "subtle", "zeroize", @@ -2285,6 +3455,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "serde", "strsim 0.11.1", "syn 2.0.117", ] @@ -2573,7 +3744,7 @@ dependencies = [ "convert_case 0.4.0", "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.117", ] @@ -2583,7 +3754,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", ] [[package]] @@ -2599,6 +3779,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", +] + [[package]] name = "diesel" version = "2.3.9" @@ -2682,10 +3876,20 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "4.0.0" @@ -2757,6 +3961,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + [[package]] name = "downcast" version = "0.11.0" @@ -2837,6 +4047,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve 0.13.8", "rfc6979 0.4.0", + "serdect", "signature 2.2.0", "spki", ] @@ -2882,11 +4093,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -2923,6 +4149,7 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1 0.7.3", + "serdect", "subtle", "zeroize", ] @@ -2965,6 +4192,26 @@ dependencies = [ "serde_yaml", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -3096,10 +4343,10 @@ dependencies = [ "aes-gcm", "aes-gcm-siv", "ark-ec", - "ark-ff", + "ark-ff 0.4.2", "ark-secp256k1", "ark-secp256r1", - "ark-serialize", + "ark-serialize 0.4.2", "auto_ops", "base64ct", "bcs", @@ -3133,7 +4380,7 @@ dependencies = [ "rfc6979 0.4.0", "rsa", "schemars 0.8.22", - "secp256k1", + "secp256k1 0.27.0", "serde", "serde_json", "serde_with", @@ -3201,10 +4448,10 @@ source = "git+https://github.com/MystenLabs/fastcrypto?rev=5f87e04bb21d295ef6b39 dependencies = [ "ark-bn254", "ark-ec", - "ark-ff", + "ark-ff 0.4.2", "ark-groth16", "ark-relations", - "ark-serialize", + "ark-serialize 0.4.2", "ark-snark", "bcs", "byte-slice-cast", @@ -3218,7 +4465,7 @@ dependencies = [ "num-bigint 0.4.6", "once_cell", "regex", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", @@ -3231,6 +4478,28 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + [[package]] name = "fd-lock" version = "4.0.4" @@ -3314,6 +4583,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.6", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -3391,6 +4672,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3516,6 +4803,12 @@ dependencies = [ "slab", ] +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + [[package]] name = "fxhash" version = "0.2.1" @@ -3791,6 +5084,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -3847,6 +5145,24 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -3943,6 +5259,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -3980,7 +5305,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -4178,7 +5503,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161ebdfec3c8e3b52bf61c4f3550a1eea4f9579d10dc1b936f3171ebdcd6c443" dependencies = [ - "parity-scale-codec", + "parity-scale-codec 2.3.1", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec 3.7.5", ] [[package]] @@ -4254,7 +5588,7 @@ dependencies = [ "anyhow", "observability", "protocol-types", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tracing", @@ -4386,6 +5720,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "interprocess" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "inventory" version = "0.3.24" @@ -4486,6 +5835,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -4597,7 +5976,7 @@ dependencies = [ "pin-project", "rustls", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.5.3", "soketto", "thiserror 1.0.69", "tokio", @@ -4648,7 +6027,7 @@ dependencies = [ "jsonrpsee-core", "jsonrpsee-types", "rustls", - "rustls-platform-verifier", + "rustls-platform-verifier 0.5.3", "serde", "serde_json", "thiserror 1.0.69", @@ -4746,6 +6125,7 @@ dependencies = [ "ecdsa 0.16.9", "elliptic-curve 0.13.8", "once_cell", + "serdect", "sha2 0.10.9", "signature 2.2.0", ] @@ -4759,6 +6139,26 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + [[package]] name = "keeper" version = "0.1.0" @@ -4774,7 +6174,7 @@ dependencies = [ "pricing", "protocol-types", "pyth-client", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -4790,6 +6190,21 @@ dependencies = [ "vault-sim", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kqueue" version = "1.1.1" @@ -5029,7 +6444,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax 0.8.10", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.117", ] @@ -5130,6 +6545,17 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "maplit" version = "1.0.2" @@ -5206,7 +6632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", - "keccak", + "keccak 0.1.6", "rand_core 0.6.4", "zeroize", ] @@ -5391,7 +6817,7 @@ dependencies = [ "pricing", "protocol-types", "pyth-client", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -5672,7 +7098,7 @@ dependencies = [ "leb128", "move-proc-macros", "num", - "primitive-types", + "primitive-types 0.10.1", "rand 0.8.6", "ref-cast", "serde", @@ -6568,7 +7994,17 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" dependencies = [ - "num_enum_derive", + "num_enum_derive 0.6.1", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive 0.7.6", + "rustversion", ] [[package]] @@ -6583,6 +8019,31 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + [[package]] name = "object" version = "0.37.3" @@ -6617,7 +8078,7 @@ dependencies = [ "percent-encoding", "quick-xml", "rand 0.10.1", - "reqwest", + "reqwest 0.12.28", "ring", "rustls-pki-types", "serde", @@ -6646,7 +8107,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-otlp", "opentelemetry_sdk", - "reqwest", + "reqwest 0.12.28", "runtime-config", "tokio", "tracing", @@ -6711,7 +8172,7 @@ dependencies = [ "bytes", "http", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -6726,7 +8187,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.13.5", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6777,7 +8238,7 @@ dependencies = [ "pricing", "protocol-types", "r2d2", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -6803,7 +8264,7 @@ dependencies = [ "observability", "protocol-types", "pyth-client", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -6823,7 +8284,7 @@ dependencies = [ "observability", "oracle-client", "pyth-client", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -6942,7 +8403,23 @@ dependencies = [ "bitvec 0.20.4", "byte-slice-cast", "impl-trait-for-tuples", - "parity-scale-codec-derive", + "parity-scale-codec-derive 2.3.1", + "serde", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec 1.0.1", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive 3.7.5", + "rustversion", "serde", ] @@ -6958,6 +8435,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -7168,6 +8657,16 @@ dependencies = [ "serde", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version 0.4.1", +] + [[package]] name = "phf" version = "0.11.3" @@ -7236,6 +8735,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -7416,7 +8921,7 @@ dependencies = [ "oracle-client", "parking_lot 0.12.5", "pricing", - "reqwest", + "reqwest 0.12.28", "runtime-config", "serde", "serde_json", @@ -7453,12 +8958,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373" dependencies = [ - "fixed-hash", - "impl-codec", + "fixed-hash 0.7.0", + "impl-codec 0.5.1", "impl-serde", "uint", ] +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash 0.8.0", + "impl-codec 0.6.0", + "uint", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -7495,11 +9011,33 @@ dependencies = [ name = "proc-macro-error-attr" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn 2.0.117", ] [[package]] @@ -7778,7 +9316,7 @@ dependencies = [ "futures", "hex", "protocol-types", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -7855,6 +9393,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "fastbloom", "getrandom 0.3.4", @@ -7998,6 +9537,7 @@ dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", ] [[package]] @@ -8008,6 +9548,7 @@ checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", + "serde", ] [[package]] @@ -8076,6 +9617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", + "serde", ] [[package]] @@ -8212,6 +9754,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.2.16" @@ -8337,7 +9885,44 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier 0.7.0", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http 0.6.10", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -8410,6 +9995,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + [[package]] name = "roaring" version = "0.10.12" @@ -8469,6 +10064,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ruint" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "parity-scale-codec 3.7.5", + "primitive-types 0.12.2", + "proptest", + "rand 0.8.6", + "rand 0.9.4", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "runtime-config" version = "0.1.0" @@ -8509,13 +10138,22 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -8524,8 +10162,8 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" dependencies = [ - "rustc_version", - "semver", + "rustc_version 0.4.1", + "semver 1.0.28", ] [[package]] @@ -8569,6 +10207,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -8608,7 +10247,7 @@ checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -8621,6 +10260,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 1.0.7", + "windows-sys 0.61.2", +] + [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" @@ -8633,6 +10293,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -8831,6 +10492,7 @@ dependencies = [ "der 0.7.10", "generic-array", "pkcs8", + "serdect", "subtle", "zeroize", ] @@ -8841,9 +10503,32 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.12.0", + "rand 0.8.6", + "secp256k1-sys 0.8.2", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.101", "rand 0.8.6", - "secp256k1-sys", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.9.4", + "secp256k1-sys 0.11.0", ] [[package]] @@ -8855,6 +10540,24 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -8878,12 +10581,36 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -9078,6 +10805,16 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct 0.2.0", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -9121,7 +10858,7 @@ checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" dependencies = [ "block-buffer 0.9.0", "digest 0.9.0", - "keccak", + "keccak 0.1.6", "opaque-debug", ] @@ -9132,7 +10869,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", ] [[package]] @@ -9219,6 +10976,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -9279,6 +11046,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "snap" @@ -9741,7 +11511,7 @@ dependencies = [ "once_cell", "prometheus", "rand 0.8.6", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_with", @@ -9759,10 +11529,10 @@ version = "0.3.0" source = "git+https://github.com/MystenLabs/sui-rust-sdk.git?rev=e494a36a76a0aab8c5d66d5557995faee5c1fb09#e494a36a76a0aab8c5d66d5557995faee5c1fb09" dependencies = [ "ark-bn254", - "ark-ff", + "ark-ff 0.4.2", "ark-groth16", "ark-snark", - "ark-std", + "ark-std 0.4.0", "base64ct", "bnum", "ed25519-dalek", @@ -10388,7 +12158,7 @@ dependencies = [ "jsonrpsee", "move-core-types", "mysten-common", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_with", @@ -10531,7 +12301,7 @@ dependencies = [ "nonempty", "num-bigint 0.4.6", "num-traits", - "num_enum", + "num_enum 0.6.1", "once_cell", "p384", "parking_lot 0.12.5", @@ -10669,6 +12439,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-solidity" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10955,7 +12737,7 @@ dependencies = [ "deployments", "observability", "protocol-types", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sui-types", @@ -11025,6 +12807,22 @@ dependencies = [ "tungstenite 0.24.0", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.28.0", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -11228,7 +13026,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots", + "webpki-roots 1.0.7", "zstd 0.13.3", ] @@ -11572,6 +13370,25 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -11717,7 +13534,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -11989,7 +13806,21 @@ dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", - "semver", + "semver 1.0.28", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.12.5", + "pin-utils", + "slab", + "wasm-bindgen", ] [[package]] @@ -12030,6 +13861,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -12039,6 +13879,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -12590,7 +14436,7 @@ dependencies = [ "id-arena", "indexmap 2.14.0", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -12629,6 +14475,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version 0.4.1", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.2.0" diff --git a/rust-backend/Cargo.toml b/rust-backend/Cargo.toml index e3ab6f28..88d4f5d9 100644 --- a/rust-backend/Cargo.toml +++ b/rust-backend/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/protocol-types", + "crates/bridge-types", + "crates/bridge-signer", "crates/runtime-config", "crates/cli-spec", "crates/pyth-client", @@ -27,6 +29,8 @@ members = [ "services/price-charting", "services/balance-monitor", "services/oracle-service", + "services/bridge-signer-service", + "services/bridge-relayer", "tools/deployment-manager", "tools/exchange", "tools/writer", @@ -46,6 +50,8 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] protocol-types = { path = "crates/protocol-types" } +bridge-types = { path = "crates/bridge-types" } +bridge-signer = { path = "crates/bridge-signer" } runtime-config = { path = "crates/runtime-config" } cli-spec = { path = "crates/cli-spec" } pyth-client = { path = "crates/pyth-client" } diff --git a/rust-backend/bridge-enclave/.dockerignore b/rust-backend/bridge-enclave/.dockerignore new file mode 100644 index 00000000..50009438 --- /dev/null +++ b/rust-backend/bridge-enclave/.dockerignore @@ -0,0 +1,5 @@ +# Keep the build context lean + deterministic (reproducible PCRs). +**/target +**/*.log +**/.DS_Store +bridge-enclave/ diff --git a/rust-backend/bridge-enclave/Dockerfile b/rust-backend/bridge-enclave/Dockerfile new file mode 100644 index 00000000..d9338c64 --- /dev/null +++ b/rust-backend/bridge-enclave/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 +# Enclave app image for bridge-signer-service (bridge_tickets/07). +# +# Build context = the `rust-backend` workspace root (the signer is a workspace +# member and needs the workspace Cargo.toml/lock to build): +# docker build -f bridge-enclave/Dockerfile -t bridge-signer-enclave . +# +# SCAFFOLD: this builds the plain signer service. Ticket 07 Phase 1 replaces the +# entrypoint with the nautilus-server (vsock + attestation + in-enclave TLS). +# +# NOT YET bit-reproducible — Phase 2 hardening TODO: +# - pin the base images BY DIGEST (FROM rust:1.90-slim@sha256:...) not by tag +# - set SOURCE_DATE_EPOCH, strip timestamps, cargo --locked (already below) +# PCR0 is only meaningful once this is deterministic. + +# ---- builder ---- +FROM rust:1.90-slim AS builder +# TODO(ticket-07): pin by digest for reproducibility. + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY . . + +# --locked: build exactly the committed Cargo.lock (reproducibility + no surprise bumps). +RUN cargo build --locked --release -p bridge-signer-service \ + && strip target/release/bridge-signer-service + +# ---- runtime ---- +FROM debian:bookworm-slim AS runtime +# TODO(ticket-07): pin by digest; consider distroless for a smaller/measured surface. + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/bridge-signer-service /usr/local/bin/bridge-signer-service + +# The signer reads its config from a path the enclave provisions. Inside a real +# Nitro enclave the nautilus-server (Phase 1) is PID 1 and starts this over vsock; +# for the scaffold image the binary is the entrypoint. +ENTRYPOINT ["/usr/local/bin/bridge-signer-service"] +CMD ["--config", "/etc/bridge-signer/config.toml"] diff --git a/rust-backend/bridge-enclave/README.md b/rust-backend/bridge-enclave/README.md new file mode 100644 index 00000000..c2d5c7eb --- /dev/null +++ b/rust-backend/bridge-enclave/README.md @@ -0,0 +1,51 @@ +# bridge-enclave + +The AWS Nitro Enclave packaging for `bridge-signer-service` (bridge-spec.md §5, +bridge_tickets/07). This directory holds the **enclave image build** and is +consumed by the `.github/workflows/bridge-enclave.yml` CI, which builds the +Enclave Image File (EIF) on a **free public-repo arm64 runner** (`ubuntu-24.04-arm`) +and measures its PCRs. + +> **Status: scaffolding (ticket 07 in progress).** Today the Dockerfile packages +> the plain `bridge-signer-service`. The Nautilus integration — the vsock +> listener, `/get_attestation`, in-enclave TLS termination for the §5.4 chain +> view, and the Seal 2-step key load (ticket 08) — is **Phase 1–4 of ticket 07 +> and not yet done**. The CI + build shape is stood up first so the rest lands +> against a working pipeline. + +## Why the EIF measurement matters + +The enclave's `PCR0` is the on-chain trust anchor: the Move `EnclaveConfig` +(ticket 07 Phase 3) will say "only an enclave measuring `PCR0 = X` is our +signer." So the build must be **reproducible** — the same source must yield the +same EIF → the same `PCR0` — and CI's job is to be the independent witness that +reproduces `PCR0` from a clean checkout. `expected_pcr0.txt` pins the approved +value; the workflow fails on drift. + +## Files + +| File | Purpose | +|------|---------| +| `Dockerfile` | Reproducible build of the enclave app image (context = the `rust-backend` workspace). | +| `.dockerignore` | Keep the build context lean + deterministic. | +| `expected_pcr0.txt` | The approved `PCR0`; the CI drift-gate compares against it (`PENDING` = first-run capture mode, no gate). | + +## Known TODOs before this is real (ticket 07) + +- **Pin the base image by digest** and the Rust toolchain, set `SOURCE_DATE_EPOCH`, + strip build timestamps — the current Dockerfile is a functional scaffold, not + yet bit-reproducible. +- **Pin the exact `nitro-cli` version** in the workflow — `PCR0` depends on the + nitro-cli / bundled-kernel version, not just the app image. +- **Nautilus wrapper:** replace the entrypoint with the nautilus-server that runs + the signer inside the enclave over vsock, exposes real attestation, and routes + all RPC egress through in-enclave TLS (ticket 07 §5.4). +- **Smoke-test to confirm** `nitro-cli build-enclave` runs on the hosted arm64 + runner (no Nitro hardware). If it can't, the workflow's build-enclave step is + the fallback boundary — move it to a self-hosted Graviton runner (restricted to + non-fork triggers) or the enclave host. See ticket 07 Phase 2. + +## Runtime (later, on the c7g.large host — ticket 07 Phase 5/6) + +The host does NOT rebuild the EIF; it runs the exact measured artifact: +`nitro-cli run-enclave --eif-path signer.eif --cpu-count 1 --memory 1536`. diff --git a/rust-backend/bridge-enclave/expected_pcr0.txt b/rust-backend/bridge-enclave/expected_pcr0.txt new file mode 100644 index 00000000..622e73de --- /dev/null +++ b/rust-backend/bridge-enclave/expected_pcr0.txt @@ -0,0 +1 @@ +PENDING diff --git a/rust-backend/crates/bridge-signer/Cargo.toml b/rust-backend/crates/bridge-signer/Cargo.toml new file mode 100644 index 00000000..f90c2e87 --- /dev/null +++ b/rust-backend/crates/bridge-signer/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bridge-signer" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +# Off-chain signing for the Layer 1 transport. At the M1 "1-of-1" launch this is +# a single-party signer; the same API becomes the threshold share-signing path +# at M3 (bridge-spec.md §1, §6). Produces signatures the on-chain Inboxes accept +# directly: Ed25519 over the digest for Sui, recoverable ECDSA for EVM. + +[dependencies] +bridge-types = { workspace = true } +thiserror = { workspace = true } +ed25519-dalek = { workspace = true } +k256 = { version = "0.13", features = ["ecdsa"] } + +[dev-dependencies] +hex = { workspace = true } diff --git a/rust-backend/crates/bridge-signer/examples/group_keys.rs b/rust-backend/crates/bridge-signer/examples/group_keys.rs new file mode 100644 index 00000000..bbcd4e94 --- /dev/null +++ b/rust-backend/crates/bridge-signer/examples/group_keys.rs @@ -0,0 +1,64 @@ +//! Derive on-chain group keys from signer seeds, and (with no args) print the +//! canonical cross-language test vectors so Rust, Move, and Solidity stay in +//! lockstep after any digest change. +//! +//! Usage: +//! cargo run -p bridge-signer --example group_keys +//! → prints the parity vectors (TEST_SALT, digest, Ed25519 sig) to bake +//! into the Rust / Move / Solidity known-vector tests. +//! cargo run -p bridge-signer --example group_keys -- +//! → prints the two group keys to register on-chain. + +use bridge_signer::ThresholdSigner; +use bridge_types::chain_id; +use bridge_types::message::derive_domain_sep; +use bridge_types::CrossChainMessage; + +fn parse(s: &str) -> [u8; 32] { + hex::decode(s.trim_start_matches("0x")).expect("hex").try_into().expect("32 bytes") +} + +fn print_group_keys(ed_seed: [u8; 32], k1_seed: [u8; 32]) { + let signer = ThresholdSigner::from_seeds(ed_seed, k1_seed).unwrap(); + println!("ed25519_group_pubkey 0x{}", hex::encode(signer.ed25519_group_pubkey())); + println!("ecdsa_group_address 0x{}", hex::encode(signer.ecdsa_group_address())); +} + +fn print_vectors() { + // Must match the tests in bridge-types/message.rs and bridge-signer/lib.rs. + let salt = [0x01u8; 32]; + let seed = [0x42u8; 32]; + let ds = derive_domain_sep(&salt); + + let to_sui = CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + 7, + [0xab; 32], + [0xcd; 32], + b"hello-bridge".to_vec(), + ); + let signer = ThresholdSigner::from_seeds(seed, [0x11u8; 32]).unwrap(); + let env = signer.sign(&to_sui, &ds, 1).unwrap(); + + println!("== cross-language parity vectors =="); + println!("TEST_SALT 0x{}", hex::encode(salt)); + println!("DOMAIN_SEP 0x{}", hex::encode(ds)); + println!("known digest {}", hex::encode(to_sui.digest(&ds))); + println!("ed25519 group pk {}", hex::encode(signer.ed25519_group_pubkey())); + println!("ed25519 signature {}", hex::encode(&env.signature)); + println!("ecdsa group address 0x{}", hex::encode(signer.ecdsa_group_address())); + // BCS bytes the relayer passes to the Sui `bridge_receive` (message::from_bcs + // / envelope::from_bcs must decode these back to the same digest + signature). + println!("message to_move_bcs {}", hex::encode(to_sui.to_move_bcs())); + println!("envelope to_move_bcs {}", hex::encode(env.to_move_bcs())); +} + +fn main() { + let args: Vec = std::env::args().collect(); + match args.len() { + 1 => print_vectors(), + 3 => print_group_keys(parse(&args[1]), parse(&args[2])), + _ => panic!("usage: group_keys [ ]"), + } +} diff --git a/rust-backend/crates/bridge-signer/examples/roundtrip_helper.rs b/rust-backend/crates/bridge-signer/examples/roundtrip_helper.rs new file mode 100644 index 00000000..d9e4bdb4 --- /dev/null +++ b/rust-backend/crates/bridge-signer/examples/roundtrip_helper.rs @@ -0,0 +1,49 @@ +//! Manual-relay helper: reconstruct a committed message, sign it with the +//! round-trip demo group keys, and emit everything needed to submit on the +//! destination — the digest (to cross-check the on-chain messageHash), the +//! signature, and the Move-BCS bytes for a Sui `bridge_receive` PTB. +//! +//! Seeds are the demo group keys: ed25519 [0x42;32] (pubkey 2152f8…), +//! secp256k1 [0x11;32] (address 19e7…). +//! +//! Args: +//! + +use bridge_signer::ThresholdSigner; +use bridge_types::message::derive_domain_sep; +use bridge_types::CrossChainMessage; + +fn b32(s: &str) -> [u8; 32] { + let mut v = hex::decode(s.trim_start_matches("0x")).unwrap(); + // left-pad to 32 (EVM addresses arrive as 20/variable) + while v.len() < 32 { + v.insert(0, 0); + } + v.try_into().unwrap() +} + +fn main() { + let a: Vec = std::env::args().collect(); + let src: u32 = a[1].parse().unwrap(); + let dst: u32 = a[2].parse().unwrap(); + let nonce: u64 = a[3].parse().unwrap(); + let src_app = b32(&a[4]); + let dst_app = b32(&a[5]); + let payload = hex::decode(a[6].trim_start_matches("0x")).unwrap(); + let salt = b32(&a[7]); + let group_pubkey_id: u32 = a[8].parse().unwrap(); + + let ds = derive_domain_sep(&salt); + let msg = CrossChainMessage::new(src, dst, nonce, src_app, dst_app, payload); + let signer = ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(); + let env = signer.sign(&msg, &ds, group_pubkey_id).unwrap(); + + println!("DIGEST=0x{}", hex::encode(msg.digest(&ds))); + println!("SCHEME={}", env.scheme_tag); + println!("SIG=0x{}", hex::encode(&env.signature)); + println!("MSG_BCS=0x{}", hex::encode(msg.to_move_bcs())); + println!("ENV_BCS=0x{}", hex::encode(env.to_move_bcs())); + // EVM-shaped fields (for cast receiveMessage tuple), too: + println!("SRC_APP=0x{}", hex::encode(src_app)); + println!("DST_APP=0x{}", hex::encode(dst_app)); +} diff --git a/rust-backend/crates/bridge-signer/src/lib.rs b/rust-backend/crates/bridge-signer/src/lib.rs new file mode 100644 index 00000000..742da3ee --- /dev/null +++ b/rust-backend/crates/bridge-signer/src/lib.rs @@ -0,0 +1,184 @@ +//! Off-chain signing for the Layer 1 transport. +//! +//! Given a [`CrossChainMessage`], the signer computes its keccak256 digest and +//! signs it with the scheme the *destination* family verifies (bridge-spec.md +//! §2.3): +//! - Sui → Ed25519 over the digest bytes (the Move `ed25519_verify` path). +//! - EVM → recoverable ECDSA/secp256k1 over the digest (the Solidity +//! `ecrecover` path): 65 bytes `r || s || v`, low-`s`, `v ∈ {27, 28}`. +//! +//! At M1 this is a single keypair per curve ("1-of-1"); at M3 the same surface +//! becomes threshold share-signing, with the aggregated signature shaped +//! identically so nothing on-chain changes. + +use bridge_types::chain_id; +use bridge_types::message::keccak256; +use bridge_types::{Bytes32, CrossChainMessage, Scheme, SignatureEnvelope}; +use ed25519_dalek::Signer as _; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SignerError { + #[error("destination family {0} has no supported signature scheme")] + UnsupportedDstFamily(u8), + #[error("ecdsa signing failed: {0}")] + Ecdsa(#[from] k256::ecdsa::Error), +} + +pub struct ThresholdSigner { + ed25519: ed25519_dalek::SigningKey, + secp256k1: k256::ecdsa::SigningKey, +} + +impl ThresholdSigner { + /// Build a signer from one 32-byte seed per curve. The secp256k1 seed must + /// be a valid non-zero scalar below the curve order. + pub fn from_seeds(ed25519_seed: [u8; 32], secp256k1_seed: [u8; 32]) -> Result { + Ok(Self { + ed25519: ed25519_dalek::SigningKey::from_bytes(&ed25519_seed), + secp256k1: k256::ecdsa::SigningKey::from_slice(&secp256k1_seed)?, + }) + } + + /// The 32-byte Ed25519 group public key (registered as the Sui group key). + pub fn ed25519_group_pubkey(&self) -> Bytes32 { + self.ed25519.verifying_key().to_bytes() + } + + /// The 20-byte ECDSA group address (registered as the EVM group key); + /// `keccak256(uncompressed_pubkey[1..])[12..]`, i.e. the Ethereum address. + pub fn ecdsa_group_address(&self) -> [u8; 20] { + let vk = self.secp256k1.verifying_key(); + let point = vk.to_encoded_point(false); // 0x04 || X || Y + let hash = keccak256(&point.as_bytes()[1..]); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&hash[12..]); + addr + } + + /// Sign `message` for its destination chain, returning a ready envelope. + /// `domain_sep` is [`bridge_types::message::derive_domain_sep`] of the + /// deployment salt — the digest is domain-separated (spec §2.2). + pub fn sign( + &self, + message: &CrossChainMessage, + domain_sep: &Bytes32, + group_pubkey_id: u32, + ) -> Result { + let family = chain_id::family(message.dst_chain_id); + let scheme = + Scheme::for_family(family).ok_or(SignerError::UnsupportedDstFamily(family))?; + let digest = message.digest(domain_sep); + let signature = match scheme { + Scheme::Ed25519 => self.sign_ed25519(&digest).to_vec(), + Scheme::EcdsaSecp256k1 => self.sign_ecdsa_recoverable(&digest)?.to_vec(), + }; + Ok(SignatureEnvelope::new(scheme, group_pubkey_id, signature)) + } + + /// Raw 64-byte Ed25519 signature over the digest (PureEdDSA). + pub fn sign_ed25519(&self, digest: &Bytes32) -> [u8; 64] { + self.ed25519.sign(digest).to_bytes() + } + + /// 65-byte recoverable ECDSA signature `r || s || v` over the prehashed + /// digest, low-`s` normalized, `v ∈ {27, 28}` for `ecrecover`. + pub fn sign_ecdsa_recoverable(&self, digest: &Bytes32) -> Result<[u8; 65], SignerError> { + let (sig, recid) = self.secp256k1.sign_prehash_recoverable(digest)?; + let mut out = [0u8; 65]; + out[..64].copy_from_slice(&sig.to_bytes()); + out[64] = 27 + recid.to_byte(); + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_types::message::derive_domain_sep; + + /// Fixed dummy salt shared with the Move + Solidity parity tests. + const TEST_SALT: Bytes32 = [0x01; 32]; + + fn vector_message_to_sui() -> CrossChainMessage { + CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + 7, + [0xab; 32], + [0xcd; 32], + b"hello-bridge".to_vec(), + ) + } + + /// The Ed25519 path reproduces the EXACT vector the Move + Solidity tests + /// embed (key seed [0x42; 32] over the known digest). Ed25519 is + /// deterministic (RFC 8032), so a correct signer is byte-identical to the + /// signature the Sui Inbox verifies. + #[test] + fn ed25519_matches_onchain_vector() { + let signer = ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(); + assert_eq!( + hex::encode(signer.ed25519_group_pubkey()), + "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12" + ); + let ds = derive_domain_sep(&TEST_SALT); + let env = signer.sign(&vector_message_to_sui(), &ds, 1).unwrap(); + assert_eq!(env.scheme_tag, bridge_types::envelope::SCHEME_ED25519); + assert_eq!( + hex::encode(&env.signature), + "12bc85a949906a86bdea305aa6bc32ef704e77de62ea5fb65a3df3a39902e533\ + 98ca95da28a3c34aa8187edcf8f6936330c94016e1e1c4d3f2f7b80027190001" + ); + } + + /// The ECDSA path produces a recoverable signature that recovers to the + /// registered group address — i.e. exactly what Solidity `ecrecover` checks. + #[test] + fn ecdsa_recovers_to_group_address() { + let signer = ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(); + // Destination EVM → ECDSA scheme selected. + let message = CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + 0, + [0x11; 32], + [0x22; 32], + b"p".to_vec(), + ); + let ds = derive_domain_sep(&TEST_SALT); + let digest = message.digest(&ds); + let env = signer.sign(&message, &ds, 1).unwrap(); + assert_eq!(env.scheme_tag, bridge_types::envelope::SCHEME_ECDSA_SECP256K1); + assert_eq!(env.signature.len(), 65); + let v = env.signature[64]; + assert!(v == 27 || v == 28); + + // Recover the signer address from (r, s, v) and compare to the group key. + let recid = k256::ecdsa::RecoveryId::from_byte(v - 27).unwrap(); + let sig = k256::ecdsa::Signature::from_slice(&env.signature[..64]).unwrap(); + let vk = k256::ecdsa::VerifyingKey::recover_from_prehash(&digest, &sig, recid).unwrap(); + let point = vk.to_encoded_point(false); + let hash = keccak256(&point.as_bytes()[1..]); + assert_eq!(&hash[12..], signer.ecdsa_group_address()); + } + + #[test] + fn rejects_unknown_destination_family() { + let signer = ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(); + // dst_chain_id with family bits = 3 (Solana) — no Sui/EVM verifier here. + let message = CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 1).unwrap(), + chain_id::encode(chain_id::FAMILY_SOLANA, 1).unwrap(), + 0, + [0u8; 32], + [0u8; 32], + vec![], + ); + let ds = derive_domain_sep(&TEST_SALT); + assert!(matches!( + signer.sign(&message, &ds, 1), + Err(SignerError::UnsupportedDstFamily(chain_id::FAMILY_SOLANA)) + )); + } +} diff --git a/rust-backend/crates/bridge-types/Cargo.toml b/rust-backend/crates/bridge-types/Cargo.toml new file mode 100644 index 00000000..26f62d48 --- /dev/null +++ b/rust-backend/crates/bridge-types/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bridge-types" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +# Layer 1 canonical message types shared by the signer node and the relayer. +# This is the THIRD implementation of the wire format (after the Move +# `sui_bridge::message` and the Solidity `Message.sol`); all three must produce +# a byte-identical keccak256 digest. The `known_digest_vector` test pins it. + +[dependencies] +serde = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } +tiny-keccak = { version = "2", features = ["keccak"] } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/rust-backend/crates/bridge-types/src/chain_id.rs b/rust-backend/crates/bridge-types/src/chain_id.rs new file mode 100644 index 00000000..52c053e0 --- /dev/null +++ b/rust-backend/crates/bridge-types/src/chain_id.rs @@ -0,0 +1,78 @@ +//! Self-describing internal chain id, identical to `sui_bridge::chain_id` (Move) +//! and `ChainId.sol` (Solidity). +//! +//! `internal_id = (family << 27) | local` +//! - top 5 bits : family (1=Sui, 2=EVM, 3=Solana, 4=Aptos) +//! - low 27 bits : per-family local id +//! +//! The 27-bit local field caps at 134,217,727. For EVM it SHOULD be the native +//! chainId when it fits (HyperEVM testnet = 998 does); otherwise an assigned +//! index, with the registry's native identifier holding the authoritative value. + +use thiserror::Error; + +pub const FAMILY_SUI: u8 = 1; +pub const FAMILY_EVM: u8 = 2; +pub const FAMILY_SOLANA: u8 = 3; +pub const FAMILY_APTOS: u8 = 4; + +const FAMILY_SHIFT: u32 = 27; +const FAMILY_MASK: u32 = 0x1F; // 5 bits +pub const LOCAL_MASK: u32 = 0x07FF_FFFF; // low 27 bits + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ChainIdError { + #[error("unknown family {0}")] + UnknownFamily(u8), + #[error("local id {0} exceeds 27-bit ceiling")] + LocalTooLarge(u32), +} + +pub fn is_valid_family(family: u8) -> bool { + (FAMILY_SUI..=FAMILY_APTOS).contains(&family) +} + +/// Compose an internal id from `family` and a 27-bit `local` id. +pub fn encode(family: u8, local: u32) -> Result { + if !is_valid_family(family) { + return Err(ChainIdError::UnknownFamily(family)); + } + if local > LOCAL_MASK { + return Err(ChainIdError::LocalTooLarge(local)); + } + Ok(((family as u32) << FAMILY_SHIFT) | local) +} + +/// The family tag (top 5 bits). +pub fn family(internal_id: u32) -> u8 { + ((internal_id >> FAMILY_SHIFT) & FAMILY_MASK) as u8 +} + +/// The per-family local id (low 27 bits). +pub fn local(internal_id: u32) -> u32 { + internal_id & LOCAL_MASK +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_matches_other_chains() { + let hyper = encode(FAMILY_EVM, 998).unwrap(); + let sui = encode(FAMILY_SUI, 0).unwrap(); + // Same constants the Move + Solidity tests assert. + assert_eq!(hyper, 268_436_454); + assert_eq!(sui, 134_217_728); + assert_eq!(family(hyper), FAMILY_EVM); + assert_eq!(local(hyper), 998); + assert_eq!(family(sui), FAMILY_SUI); + assert_eq!(local(sui), 0); + } + + #[test] + fn rejects_bad_family_and_oversized_local() { + assert_eq!(encode(9, 1), Err(ChainIdError::UnknownFamily(9))); + assert_eq!(encode(FAMILY_EVM, LOCAL_MASK + 1), Err(ChainIdError::LocalTooLarge(LOCAL_MASK + 1))); + } +} diff --git a/rust-backend/crates/bridge-types/src/envelope.rs b/rust-backend/crates/bridge-types/src/envelope.rs new file mode 100644 index 00000000..0182e510 --- /dev/null +++ b/rust-backend/crates/bridge-types/src/envelope.rs @@ -0,0 +1,91 @@ +//! Signature envelope carried alongside a delivered message (bridge-spec.md +//! §2.3). Mirrors `sui_bridge::envelope` and `Envelope.sol`: the Inbox selects +//! the verifier by `scheme_tag` and looks the group key up by `group_pubkey_id`. + +use serde::{Deserialize, Serialize}; + +pub const SCHEME_ED25519: u8 = 0; +pub const SCHEME_ECDSA_SECP256K1: u8 = 1; + +/// Signature scheme tag. Ed25519 (FROST) is used for Sui-destined messages, +/// ECDSA/secp256k1 (GG20) for EVM-destined ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Ed25519, + EcdsaSecp256k1, +} + +impl Scheme { + pub fn tag(self) -> u8 { + match self { + Scheme::Ed25519 => SCHEME_ED25519, + Scheme::EcdsaSecp256k1 => SCHEME_ECDSA_SECP256K1, + } + } + + pub fn from_tag(tag: u8) -> Option { + match tag { + SCHEME_ED25519 => Some(Scheme::Ed25519), + SCHEME_ECDSA_SECP256K1 => Some(Scheme::EcdsaSecp256k1), + _ => None, + } + } + + /// The scheme a destination chain family verifies with. + pub fn for_family(family: u8) -> Option { + match family { + crate::chain_id::FAMILY_EVM => Some(Scheme::EcdsaSecp256k1), + crate::chain_id::FAMILY_SUI => Some(Scheme::Ed25519), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignatureEnvelope { + pub scheme_tag: u8, + pub group_pubkey_id: u32, + #[serde(with = "crate::message::hex_vec")] + pub signature: Vec, +} + +impl SignatureEnvelope { + pub fn new(scheme: Scheme, group_pubkey_id: u32, signature: Vec) -> Self { + Self { scheme_tag: scheme.tag(), group_pubkey_id, signature } + } + + /// Standard **BCS** serialization matching `sui_bridge::envelope::from_bcs` + /// (scheme_tag, group_pubkey_id, signature) — the plain `vector` arg a + /// relayer passes to the Sui `bridge_receive`. + pub fn to_move_bcs(&self) -> Vec { + let mut out = Vec::new(); + out.push(self.scheme_tag); + out.extend_from_slice(&self.group_pubkey_id.to_le_bytes()); + crate::message::push_uleb128(&mut out, self.signature.len() as u64); + out.extend_from_slice(&self.signature); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_id; + + #[test] + fn scheme_tag_round_trip_and_family_mapping() { + assert_eq!(Scheme::from_tag(Scheme::Ed25519.tag()), Some(Scheme::Ed25519)); + assert_eq!(Scheme::from_tag(Scheme::EcdsaSecp256k1.tag()), Some(Scheme::EcdsaSecp256k1)); + assert_eq!(Scheme::from_tag(9), None); + assert_eq!(Scheme::for_family(chain_id::FAMILY_EVM), Some(Scheme::EcdsaSecp256k1)); + assert_eq!(Scheme::for_family(chain_id::FAMILY_SUI), Some(Scheme::Ed25519)); + } + + /// BCS layout: scheme_tag (u8), group_pubkey_id (u32 LE), signature + /// (ULEB128 len + bytes) — what `sui_bridge::envelope::from_bcs` decodes. + #[test] + fn to_move_bcs_layout() { + let e = SignatureEnvelope { scheme_tag: 0, group_pubkey_id: 1, signature: vec![0xaa, 0xbb] }; + assert_eq!(hex::encode(e.to_move_bcs()), "000100000002aabb"); + } +} diff --git a/rust-backend/crates/bridge-types/src/lib.rs b/rust-backend/crates/bridge-types/src/lib.rs new file mode 100644 index 00000000..d56fc667 --- /dev/null +++ b/rust-backend/crates/bridge-types/src/lib.rs @@ -0,0 +1,16 @@ +//! Layer 1 canonical cross-chain message types, shared by the off-chain signer +//! node and relayer (bridge-spec.md §2). +//! +//! The wire format and keccak256 digest here are byte-identical to the on-chain +//! `sui_bridge::message` (Move) and `Message.sol` (Solidity) implementations — +//! see `message::tests::known_digest_vector` for the three-way parity lock. + +pub mod chain_id; +pub mod envelope; +pub mod message; +pub mod transfer; + +pub use chain_id::ChainIdError; +pub use envelope::{Scheme, SignatureEnvelope}; +pub use message::{keccak256, Bytes32, CrossChainMessage, VERSION}; +pub use transfer::{TransferPayload, WIRE_DECIMALS}; diff --git a/rust-backend/crates/bridge-types/src/message.rs b/rust-backend/crates/bridge-types/src/message.rs new file mode 100644 index 00000000..26110fea --- /dev/null +++ b/rust-backend/crates/bridge-types/src/message.rs @@ -0,0 +1,262 @@ +//! Canonical cross-chain message + keccak256 digest. The encoding MUST be +//! byte-identical to `sui_bridge::message` (Move) and `Message.sol` (Solidity) +//! so one threshold signature verifies on every chain (bridge-spec.md §2.2). +//! +//! Fixed big-endian packed layout: +//! +//! ```text +//! version (u8) | src_chain_id (u32) | dst_chain_id (u32) | nonce (u64) +//! | src_app (32) | dst_app (32) | payload_len (u32) | payload +//! ``` +//! +//! `digest = keccak256(DOMAIN_SEP || encode(message))`, where +//! `DOMAIN_SEP = keccak256(DOMAIN_TAG || deployment_salt)` binds every signed +//! message to one logical deployment (bridge-spec.md §2.2). Without it, a +//! redeploy that reuses the registry ids (fresh `consumed` set) would let every +//! previously signed message replay. Signers sign over the 32-byte digest +//! directly (Ed25519 over the digest on Sui; ECDSA/ecrecover over the digest on +//! EVM) — no chain-specific prefix. + +use serde::{Deserialize, Serialize}; +use tiny_keccak::{Hasher, Keccak}; + +/// Current canonical format version (start at 1). +pub const VERSION: u8 = 1; + +/// Domain-separation tag hashed with the per-deployment salt to form +/// `DOMAIN_SEP`. Bump the version suffix only on a breaking digest change. +pub const DOMAIN_TAG: &[u8] = b"XCHAIN_MSG_V1"; + +/// `DOMAIN_SEP = keccak256(DOMAIN_TAG || deployment_salt)`. Derived once per +/// deployment; both chains' contracts store the derived value and the services +/// derive it identically from the salt in config. Contracts derive it on-chain +/// at construction so the stored separator is auditable. +pub fn derive_domain_sep(deployment_salt: &Bytes32) -> Bytes32 { + let mut hasher = Keccak::v256(); + let mut out = [0u8; 32]; + hasher.update(DOMAIN_TAG); + hasher.update(deployment_salt); + hasher.finalize(&mut out); + out +} + +/// 32-byte app address (Sui object/package id, or a left-padded EVM address). +pub type Bytes32 = [u8; 32]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossChainMessage { + pub version: u8, + pub src_chain_id: u32, + pub dst_chain_id: u32, + pub nonce: u64, + #[serde(with = "hex_bytes32")] + pub src_app: Bytes32, + #[serde(with = "hex_bytes32")] + pub dst_app: Bytes32, + #[serde(with = "hex_vec")] + pub payload: Vec, +} + +impl CrossChainMessage { + /// Construct a message at the current format version. + pub fn new( + src_chain_id: u32, + dst_chain_id: u32, + nonce: u64, + src_app: Bytes32, + dst_app: Bytes32, + payload: Vec, + ) -> Self { + Self { version: VERSION, src_chain_id, dst_chain_id, nonce, src_app, dst_app, payload } + } + + /// Canonical big-endian packed serialization (see module docs). + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(1 + 4 + 4 + 8 + 32 + 32 + 4 + self.payload.len()); + out.push(self.version); + out.extend_from_slice(&self.src_chain_id.to_be_bytes()); + out.extend_from_slice(&self.dst_chain_id.to_be_bytes()); + out.extend_from_slice(&self.nonce.to_be_bytes()); + out.extend_from_slice(&self.src_app); + out.extend_from_slice(&self.dst_app); + out.extend_from_slice(&(self.payload.len() as u32).to_be_bytes()); + out.extend_from_slice(&self.payload); + out + } + + /// `keccak256(domain_sep || encode())` — the 32-byte digest signers sign + /// over. `domain_sep` is [`derive_domain_sep`] of the deployment salt. + pub fn digest(&self, domain_sep: &Bytes32) -> Bytes32 { + let mut hasher = Keccak::v256(); + let mut out = [0u8; 32]; + hasher.update(domain_sep); + hasher.update(&self.encode()); + hasher.finalize(&mut out); + out + } + + /// Standard **BCS** serialization matching `sui_bridge::message::from_bcs` — + /// the bytes a relayer passes as a plain `vector` arg to the Sui + /// `bridge_receive` (relayer-dispatch-design §3.1). Field order: version, + /// src_chain_id, dst_chain_id, nonce, src_app, dst_app, payload. This is + /// distinct from [`encode`](Self::encode) (the big-endian keccak preimage); + /// BCS is little-endian with ULEB128 length prefixes. + pub fn to_move_bcs(&self) -> Vec { + let mut out = Vec::new(); + out.push(self.version); + out.extend_from_slice(&self.src_chain_id.to_le_bytes()); + out.extend_from_slice(&self.dst_chain_id.to_le_bytes()); + out.extend_from_slice(&self.nonce.to_le_bytes()); + push_bcs_bytes(&mut out, &self.src_app); + push_bcs_bytes(&mut out, &self.dst_app); + push_bcs_bytes(&mut out, &self.payload); + out + } +} + +/// Append a BCS ULEB128 length prefix. +pub(crate) fn push_uleb128(out: &mut Vec, mut v: u64) { + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + byte |= 0x80; + } + out.push(byte); + if v == 0 { + break; + } + } +} + +/// Append a BCS `vector`: ULEB128 length then the raw bytes. +fn push_bcs_bytes(out: &mut Vec, b: &[u8]) { + push_uleb128(out, b.len() as u64); + out.extend_from_slice(b); +} + +/// Left-pad a 20-byte EVM address into a 32-byte app identity (spec §2.2). +pub fn left_pad_address(addr: [u8; 20]) -> Bytes32 { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(&addr); + out +} + +pub fn keccak256(data: &[u8]) -> Bytes32 { + let mut hasher = Keccak::v256(); + let mut out = [0u8; 32]; + hasher.update(data); + hasher.finalize(&mut out); + out +} + +// --- hex (de)serialization for JSON transport --- + +pub(crate) mod hex_bytes32 { + use super::Bytes32; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(b: &Bytes32, s: S) -> Result { + s.serialize_str(&format!("0x{}", hex::encode(b))) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let s = String::deserialize(d)?; + let bytes = hex::decode(s.trim_start_matches("0x")).map_err(serde::de::Error::custom)?; + bytes.try_into().map_err(|_| serde::de::Error::custom("expected 32 bytes")) + } +} + +pub(crate) mod hex_vec { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(b: &[u8], s: S) -> Result { + s.serialize_str(&format!("0x{}", hex::encode(b))) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + hex::decode(s.trim_start_matches("0x")).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_id; + + /// Fixed dummy salt for cross-language test vectors (NOT a deployment salt). + /// Mirrored in the Move and Solidity parity tests. + pub const TEST_SALT: Bytes32 = [0x01; 32]; + + fn vector_message() -> CrossChainMessage { + CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), // 268436454 + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), // 134217728 + 7, + [0xab; 32], + [0xcd; 32], + b"hello-bridge".to_vec(), + ) + } + + /// Three-way parity lock: this exact message + TEST_SALT hashes to the same + /// digest in `sui_bridge::message_tests::known_digest_vector` (Move) and + /// `MessageTest.test_known_digest_matches_sui` (Solidity). Regenerate all + /// three together via `cargo run -p bridge-signer --example group_keys`. + #[test] + fn known_digest_vector() { + let expected = "535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707"; + let ds = derive_domain_sep(&TEST_SALT); + assert_eq!(hex::encode(vector_message().digest(&ds)), expected); + } + + #[test] + fn encode_length_is_fixed_header_plus_payload() { + let m = vector_message(); + assert_eq!(m.encode().len(), 1 + 4 + 4 + 8 + 32 + 32 + 4 + m.payload.len()); + } + + #[test] + fn digest_is_field_sensitive() { + let ds = derive_domain_sep(&TEST_SALT); + let a = vector_message(); + let mut b = a.clone(); + b.nonce = 8; + assert_ne!(a.digest(&ds), b.digest(&ds)); + } + + #[test] + fn digest_is_domain_separated() { + let a = derive_domain_sep(&[0x01; 32]); + let b = derive_domain_sep(&[0x02; 32]); + let m = vector_message(); + assert_ne!(m.digest(&a), m.digest(&b)); + } + + /// BCS parity: matches the bytes `sui_bridge::message::from_bcs` decodes in + /// `message_tests::from_bcs_decodes_to_known_digest`. + #[test] + fn to_move_bcs_known_vector() { + let expected = "01e603001000000008070000000000000020\ + abababababababababababababababababababababababababababababababab20\ + cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd0c\ + 68656c6c6f2d627269646765"; + assert_eq!(hex::encode(vector_message().to_move_bcs()), expected); + } + + #[test] + fn uleb128_multibyte() { + let mut out = vec![]; + push_uleb128(&mut out, 300); // 0xAC 0x02 + assert_eq!(out, vec![0xac, 0x02]); + } + + #[test] + fn json_round_trips() { + let m = vector_message(); + let json = serde_json::to_string(&m).unwrap(); + let back: CrossChainMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + } +} diff --git a/rust-backend/crates/bridge-types/src/transfer.rs b/rust-backend/crates/bridge-types/src/transfer.rs new file mode 100644 index 00000000..056f9a08 --- /dev/null +++ b/rust-backend/crates/bridge-types/src/transfer.rs @@ -0,0 +1,92 @@ +//! Layer 2 transfer payload — the bytes a Locker puts in +//! `CrossChainMessage.payload` (bridge-spec.md §3.2/§3.3, NTT-style). +//! +//! Fixed big-endian packed layout (72 bytes), byte-identical to the Move +//! `locker::transfer_payload` and Solidity `TransferPayload` implementations: +//! +//! ```text +//! asset_id bytes32 32 bytes +//! amount u64 big-endian 8 bytes (in fixed WIRE_DECIMALS, see below) +//! recipient bytes32 32 bytes +//! ``` +//! +//! `amount` is a *wire amount* in a fixed decimal precision shared by both +//! chains (`WIRE_DECIMALS`), so an 18-dec ERC-20 and an N-dec `Coin` agree on +//! the integer carried across. Each Locker scales between its local decimals and +//! the wire precision, rejecting any remainder (dust) — the NTT "trimmed amount" +//! approach. The codec itself is agnostic to decimals; it just carries the u64. + +use crate::message::Bytes32; +use thiserror::Error; + +/// Shared wire precision for cross-chain amounts (Locker scaling target). +pub const WIRE_DECIMALS: u8 = 8; + +/// Encoded length: 32 + 8 + 32. +pub const ENCODED_LEN: usize = 72; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferPayload { + pub asset_id: Bytes32, + pub amount: u64, + pub recipient: Bytes32, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TransferDecodeError { + #[error("transfer payload must be {ENCODED_LEN} bytes, got {0}")] + BadLength(usize), +} + +impl TransferPayload { + pub fn new(asset_id: Bytes32, amount: u64, recipient: Bytes32) -> Self { + Self { asset_id, amount, recipient } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(ENCODED_LEN); + out.extend_from_slice(&self.asset_id); + out.extend_from_slice(&self.amount.to_be_bytes()); + out.extend_from_slice(&self.recipient); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != ENCODED_LEN { + return Err(TransferDecodeError::BadLength(bytes.len())); + } + let mut asset_id = [0u8; 32]; + asset_id.copy_from_slice(&bytes[0..32]); + let mut amount_be = [0u8; 8]; + amount_be.copy_from_slice(&bytes[32..40]); + let mut recipient = [0u8; 32]; + recipient.copy_from_slice(&bytes[40..72]); + Ok(Self { asset_id, amount: u64::from_be_bytes(amount_be), recipient }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parity vector: this exact payload encodes identically in the Move and + /// Solidity codecs. + #[test] + fn known_encoding_vector() { + let p = TransferPayload::new([0x11; 32], 123_456_789, [0x22; 32]); + let expected = format!("{}{}{}", "11".repeat(32), "00000000075bcd15", "22".repeat(32)); + assert_eq!(hex::encode(p.encode()), expected); + assert_eq!(p.encode().len(), ENCODED_LEN); + } + + #[test] + fn round_trips() { + let p = TransferPayload::new([0xab; 32], u64::MAX, [0xcd; 32]); + assert_eq!(TransferPayload::decode(&p.encode()).unwrap(), p); + } + + #[test] + fn rejects_bad_length() { + assert_eq!(TransferPayload::decode(&[0u8; 71]), Err(TransferDecodeError::BadLength(71))); + } +} diff --git a/rust-backend/infra-bridge/.gitignore b/rust-backend/infra-bridge/.gitignore new file mode 100644 index 00000000..e80e9567 --- /dev/null +++ b/rust-backend/infra-bridge/.gitignore @@ -0,0 +1,8 @@ +# Terraform state + plugins + local vars (never commit) +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log diff --git a/rust-backend/infra-bridge/README.md b/rust-backend/infra-bridge/README.md new file mode 100644 index 00000000..db2879ee --- /dev/null +++ b/rust-backend/infra-bridge/README.md @@ -0,0 +1,51 @@ +# infra-bridge + +Isolated Terraform for the bridge signer's **AWS Nitro enclave host** +(bridge_tickets/07 Phase 5). Stands up a single `c7g.large` Graviton instance +that's Nitro-Enclave-ready, plus its ECR repo, IAM, and security group. + +## Why a separate root + +Deliberately **not** part of `rust-backend/infra/`: +- Own (local) state — no shared state with the main root, which carries a known + destructive-drift landmine (its `ecr.tf` `for_each` with `state rm` warnings). + So `apply` here never needs `-target` gymnastics. +- Different arch/OS (arm64 Graviton + Nitro vs the main root's amd64 Ubuntu). + +It **reuses** the main VPC + a public subnet via data-source lookup (by the +`options-vpc` / `options-public-0` tags) — it does not recreate networking. + +## What it creates + +| Resource | Notes | +|---|---| +| `aws_instance.enclave` | `c7g.large`, AL2023 arm64, **`enclave_options { enabled = true }`**, IMDSv2, gp3 30 GB. `user_data` installs `nitro-cli` + docker, configures the allocator (1 vCPU / 1536 MiB to the enclave), enables SSM. | +| `aws_ecr_repository.enclave` | `options-bridge-signer-enclave`, immutable tags. Push target for `bridge-enclave.yml`. | +| IAM role + instance profile | SSM (no SSH) + ECR pull (scoped to the repo). | +| Security group | Egress all (HTTPS/SSM/ECR/RPC/Seal); ingress none by default (tcp/3000 only if `signer_api_ingress_cidrs` set). | + +The host comes up **enclave-ready but not running an enclave** — there's no EIF +until ticket 07 Phase 1. After apply, build the EIF (CI) and run it over SSM. + +## Run it + +```bash +cd rust-backend/infra-bridge +cp terraform.tfvars.example terraform.tfvars # tweak if needed +terraform init +terraform plan +terraform apply +``` + +Then wire CI: set the repo variable **`BRIDGE_ENCLAVE_ECR_REPO`** to the +`ecr_repo_url` output's repo name so `.github/workflows/bridge-enclave.yml` +pushes to it. Reach the host with the `ssm_session_hint` output (SSM, no SSH). + +## Caveats (verify on first apply — I couldn't run this) + +- **Package names:** `aws-nitro-enclaves-cli{,-devel}` + `docker` via `dnf` on the + pinned AL2023 release. If a name differs, adjust `templates/user_data.sh.tftpl`. +- **Nitro CLI version affects PCR0** — pin it deliberately once we lock the build. +- **N=1 now.** For N≥3 (ticket 09), refactor the instance/IAM/SG into a + `bridge_signer_node` module and `for_each` over operators/subnets. +- Local state; add an S3 `backend` block before this is a shared/team resource. diff --git a/rust-backend/infra-bridge/data.tf b/rust-backend/infra-bridge/data.tf new file mode 100644 index 00000000..62d1ccfd --- /dev/null +++ b/rust-backend/infra-bridge/data.tf @@ -0,0 +1,18 @@ +data "aws_caller_identity" "current" {} + +# Reuse the main infra's VPC + a public subnet (looked up by the tags the main +# root sets: "-vpc", "-public-0"). We don't recreate networking. +data "aws_vpc" "main" { + tags = { Name = "${var.project}-vpc" } +} + +data "aws_subnet" "public" { + vpc_id = data.aws_vpc.main.id + tags = { Name = "${var.project}-public-0" } +} + +# Latest Amazon Linux 2023 arm64 AMI (AL2023 has nitro-cli in dnf + SSM agent +# preinstalled). Pinned against replacement via ignore_changes on the instance. +data "aws_ssm_parameter" "al2023_arm64" { + name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64" +} diff --git a/rust-backend/infra-bridge/ec2.tf b/rust-backend/infra-bridge/ec2.tf new file mode 100644 index 00000000..6d56265d --- /dev/null +++ b/rust-backend/infra-bridge/ec2.tf @@ -0,0 +1,38 @@ +resource "aws_instance" "enclave" { + ami = data.aws_ssm_parameter.al2023_arm64.value + instance_type = var.instance_type + subnet_id = data.aws_subnet.public.id + vpc_security_group_ids = [aws_security_group.enclave.id] + iam_instance_profile = aws_iam_instance_profile.enclave.name + associate_public_ip_address = true # public subnet: egress for SSM/ECR/RPC without a NAT + + # The whole point: enable Nitro Enclaves on this host. + enclave_options { + enabled = true + } + + # IMDSv2 only. + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + } + + root_block_device { + volume_type = "gp3" + volume_size = var.root_volume_gb + encrypted = true + } + + user_data = templatefile("${path.module}/templates/user_data.sh.tftpl", { + enclave_cpu_count = var.enclave_cpu_count + enclave_memory_mib = var.enclave_memory_mib + }) + + tags = { Name = "${var.project}-bridge-enclave" } + + # A new AL2023 AMI release must not silently force-replace the running enclave + # host. Upgrade deliberately (taint + apply) when desired. + lifecycle { + ignore_changes = [ami] + } +} diff --git a/rust-backend/infra-bridge/ecr.tf b/rust-backend/infra-bridge/ecr.tf new file mode 100644 index 00000000..0a16f96c --- /dev/null +++ b/rust-backend/infra-bridge/ecr.tf @@ -0,0 +1,14 @@ +# Standalone ECR repo for the enclave app image. Kept in THIS root (not the main +# infra's `aws_ecr_repository.svc` for_each map) so we don't touch the drift +# landmine there. The CI (bridge-enclave.yml) pushes here on manual deploy. +resource "aws_ecr_repository" "enclave" { + name = var.ecr_repo_name + image_tag_mutability = "IMMUTABLE" # pin-by-digest discipline for reproducible PCRs + force_delete = false + + image_scanning_configuration { + scan_on_push = true + } + + tags = { Name = var.ecr_repo_name } +} diff --git a/rust-backend/infra-bridge/iam.tf b/rust-backend/infra-bridge/iam.tf new file mode 100644 index 00000000..c5a7771a --- /dev/null +++ b/rust-backend/infra-bridge/iam.tf @@ -0,0 +1,50 @@ +data "aws_iam_policy_document" "ec2_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "enclave" { + name = "${var.project}-bridge-enclave" + assume_role_policy = data.aws_iam_policy_document.ec2_assume.json +} + +# SSM manages the host (no SSH). AL2023 ships the SSM agent. +resource "aws_iam_role_policy_attachment" "ssm" { + role = aws_iam_role.enclave.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +# ECR pull of the enclave image (scoped to our repo; the auth-token call is +# account-wide and cannot be resource-scoped). +data "aws_iam_policy_document" "enclave_inline" { + statement { + sid = "EcrAuth" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + statement { + sid = "EcrPull" + actions = [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + ] + resources = [aws_ecr_repository.enclave.arn] + } +} + +resource "aws_iam_role_policy" "enclave_inline" { + name = "${var.project}-bridge-enclave" + role = aws_iam_role.enclave.id + policy = data.aws_iam_policy_document.enclave_inline.json +} + +resource "aws_iam_instance_profile" "enclave" { + name = "${var.project}-bridge-enclave" + role = aws_iam_role.enclave.name +} diff --git a/rust-backend/infra-bridge/iam_ci.tf b/rust-backend/infra-bridge/iam_ci.tf new file mode 100644 index 00000000..9da93d1d --- /dev/null +++ b/rust-backend/infra-bridge/iam_ci.tf @@ -0,0 +1,71 @@ +# Dedicated OIDC deploy role for bridge-enclave.yml (the workflow header's +# "its own ECR repo + IAM role from the isolated infra-bridge root"). The main +# root's options-gh-actions-deploy only trusts refs/heads/{staging,main}, and +# its trust policy is main-root-managed — so the bridge deploy gets its own +# role instead of an out-of-band edit there. Set the repo var +# BRIDGE_DEPLOY_ROLE_ARN to this role's ARN. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "bridge_gh_assume" { + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = [ + "repo:ewitulsk/SuiOptions:ref:refs/heads/ewitulsk/sui-bridge", + "repo:ewitulsk/SuiOptions:ref:refs/heads/staging", + "repo:ewitulsk/SuiOptions:ref:refs/heads/main", + ] + } + } +} + +resource "aws_iam_role" "bridge_gh_deploy" { + name = "${var.project}-bridge-gh-deploy" + assume_role_policy = data.aws_iam_policy_document.bridge_gh_assume.json +} + +data "aws_iam_policy_document" "gh_actions_bridge_ecr_push" { + statement { + sid = "EcrAuth" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + statement { + sid = "BridgeEnclaveEcrPush" + actions = [ + "ecr:UploadLayerPart", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:DescribeImages", + "ecr:CompleteLayerUpload", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + ] + resources = [aws_ecr_repository.enclave.arn] + } +} + +resource "aws_iam_role_policy" "gh_actions_bridge_ecr_push" { + name = "${var.project}-bridge-enclave-ecr-push" + role = aws_iam_role.bridge_gh_deploy.id + policy = data.aws_iam_policy_document.gh_actions_bridge_ecr_push.json +} + +output "bridge_deploy_role_arn" { + description = "Set the repo var BRIDGE_DEPLOY_ROLE_ARN to this." + value = aws_iam_role.bridge_gh_deploy.arn +} diff --git a/rust-backend/infra-bridge/outputs.tf b/rust-backend/infra-bridge/outputs.tf new file mode 100644 index 00000000..a02636c6 --- /dev/null +++ b/rust-backend/infra-bridge/outputs.tf @@ -0,0 +1,26 @@ +output "instance_id" { + description = "Enclave host EC2 instance id." + value = aws_instance.enclave.id +} + +output "public_ip" { + value = aws_instance.enclave.public_ip +} + +output "private_ip" { + value = aws_instance.enclave.private_ip +} + +output "ecr_repo_url" { + description = "Set the CI var BRIDGE_ENCLAVE_ECR_REPO to the repo name; push here." + value = aws_ecr_repository.enclave.repository_url +} + +output "security_group_id" { + value = aws_security_group.enclave.id +} + +output "ssm_session_hint" { + description = "Reach the host (no SSH — SSM only)." + value = "aws ssm start-session --target ${aws_instance.enclave.id} --region ${var.aws_region}" +} diff --git a/rust-backend/infra-bridge/security_groups.tf b/rust-backend/infra-bridge/security_groups.tf new file mode 100644 index 00000000..4d10f8b7 --- /dev/null +++ b/rust-backend/infra-bridge/security_groups.tf @@ -0,0 +1,34 @@ +resource "aws_security_group" "enclave" { + name_prefix = "${var.project}-bridge-enclave-" + description = "Bridge signer enclave host" + vpc_id = data.aws_vpc.main.id + + # Egress: allow all — SSM, ECR, RPC providers, and Seal servers are all HTTPS + # to varied endpoints. Tighten to 443 + DNS(53) once the exact set is fixed. + egress { + description = "all outbound (SSM/ECR/RPC/Seal)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + # Ingress: signer public API (tcp/3000) only if configured (e.g. the relayer). + # Default empty → no inbound; the host is managed via SSM, not SSH. + dynamic "ingress" { + for_each = length(var.signer_api_ingress_cidrs) > 0 ? [1] : [] + content { + description = "signer public API (/sign_requests)" + from_port = 3000 + to_port = 3000 + protocol = "tcp" + cidr_blocks = var.signer_api_ingress_cidrs + } + } + + tags = { Name = "${var.project}-bridge-enclave" } + + lifecycle { + create_before_destroy = true + } +} diff --git a/rust-backend/infra-bridge/templates/user_data.sh.tftpl b/rust-backend/infra-bridge/templates/user_data.sh.tftpl new file mode 100644 index 00000000..22de03e1 --- /dev/null +++ b/rust-backend/infra-bridge/templates/user_data.sh.tftpl @@ -0,0 +1,30 @@ +#!/bin/bash +# Provision the Graviton host to be Nitro-Enclave-ready (bridge_tickets/07 Phase 5/6). +# It does NOT run an enclave yet — there's no EIF until Phase 1. Once the EIF is +# built (CI), run it over SSM: `nitro-cli run-enclave --eif-path signer.eif ...`. +set -euxo pipefail + +dnf -y update + +# Nitro Enclaves CLI (+ devel for build-enclave) and docker (image runs / build). +# NOTE(ticket-07): verify these package names on the pinned AL2023 release; the +# Nitro CLI version affects PCR0, so pin it deliberately for reproducibility. +dnf -y install aws-nitro-enclaves-cli aws-nitro-enclaves-cli-devel docker + +# Allow ec2-user (and root/SSM) to drive the enclave + docker. +usermod -aG ne ec2-user || true +usermod -aG docker ec2-user || true + +# Dedicate ${enclave_cpu_count} vCPU + ${enclave_memory_mib} MiB to enclaves. +mkdir -p /etc/nitro_enclaves +cat > /etc/nitro_enclaves/allocator.yaml < Correction (2026-07-01): an earlier note here claimed `reqwest`'s outbound HTTPS +> to public fullnodes hangs after connect in this sandbox. Re-tested and it does +> NOT reproduce — curl, reqwest (all client configs), and sui-sdk all reach +> `fullnode.testnet.sui.io` in ~0.2–0.3s. The relayer binary is not egress-blocked +> against Sui; only its destination *write* path hasn't been exercised end-to-end +> yet (the round trip in DEPLOYMENTS.md was CLI/cast-driven). + +## Tests + +`cargo test -p bridge-relayer` covers the event decoder (both Sui JSON shapes + +hash-mismatch rejection) and the relay orchestration (a known message flows +through to a recorded submission carrying the exact on-chain-valid signature, +then dedups on the second pass). diff --git a/rust-backend/services/bridge-relayer/config.example.toml b/rust-backend/services/bridge-relayer/config.example.toml new file mode 100644 index 00000000..3816f428 --- /dev/null +++ b/rust-backend/services/bridge-relayer/config.example.toml @@ -0,0 +1,43 @@ +# bridge-relayer (M1). Copy and fill in. + +# Sui JSON-RPC of the source chain to watch for committed messages. +source_rpc_url = "https://fullnode.testnet.sui.io:443" + +# Deployed bridge package id (the package that holds the `events` module + the +# Outbox you want to relay from). +bridge_package_id = "0x..." + +# Signer node base URL (bridge-signer-service public port). +signer_url = "http://127.0.0.1:3000" + +# 32-byte hex per-deployment salt. The digest domain separator is +# keccak256("XCHAIN_MSG_V1" || salt) (spec §2.2). MUST match the deployed +# contracts and the signer node. +deployment_salt_hex = "${BRIDGE_DEPLOYMENT_SALT}" + +# Seconds between source polls. +poll_interval_secs = 5 + +# --- Sui source (Sui → EVM). Always on: watches the Sui Outbox above. --- + +# --- EVM destination (Sui → EVM): submit to the HyperEVM Inbox. --- +# Set all three to submit for real; omit any and EVM-bound messages are skipped. +evm_rpc_url = "https://rpcs.chain.link/hyperevm/testnet" +evm_inbox_addr = "0xD4524ce4b234c24B156631Ca612EC387de39968C" +evm_relayer_key = "${BRIDGE_EVM_RELAYER_KEY}" + +# --- EVM source (HyperEVM → Sui): watch the HyperEVM Outbox for commitments. --- +evm_source_rpc_url = "https://rpcs.chain.link/hyperevm/testnet" +evm_source_outbox_addr = "0x1797FAa1eAF0cc1fC7C092Db0035A3c46A357ff6" +evm_source_confirmations = 12 +evm_source_start_block = 0 + +# --- Sui destination (HyperEVM → Sui): deliver via the app's bridge_receive. --- +# The relayer signs + pays gas with sui_relayer_key; inbox/keys are the L1 +# shared objects (see sui-bridge-contracts/DEPLOYMENTS.md). dst_app is read from +# each message, so no per-app config. +sui_dest_rpc_url = "https://fullnode.testnet.sui.io" +sui_relayer_key = "${BRIDGE_SUI_RELAYER_KEY}" +sui_inbox_id = "0x32c3cfe0571167002fc386d7bae00a6d761ada54f70dcf4e8e18ee615c230250" +sui_group_key_registry_id = "0x16ffe9d907a9bd1bd274cc8b48bc3092ae3546f9e9d2c57e7841984468856144" +sui_gas_budget = 100000000 diff --git a/rust-backend/services/bridge-relayer/examples/evm_source_smoke.rs b/rust-backend/services/bridge-relayer/examples/evm_source_smoke.rs new file mode 100644 index 00000000..58d484af --- /dev/null +++ b/rust-backend/services/bridge-relayer/examples/evm_source_smoke.rs @@ -0,0 +1,28 @@ +//! Drive the real `EvmSourceWatcher` against a running EVM node (anvil). +//! +//! Args: +//! Prints how many committed messages the watcher reconstructed + hash-checked, +//! so a harness can assert the eth_getLogs → decode → digest-check path and the +//! confirmation-depth gate. + +use bridge_relayer::evm_source::EvmSourceWatcher; +use bridge_relayer::relay::SourceWatcher; +use bridge_types::message::derive_domain_sep; + +fn parse32(s: &str) -> [u8; 32] { + hex::decode(s.trim_start_matches("0x")).unwrap().try_into().unwrap() +} + +#[tokio::main] +async fn main() { + let a: Vec = std::env::args().collect(); + let (rpc, outbox, salt, conf) = (&a[1], &a[2], &a[3], a[4].parse::().unwrap()); + let ds = derive_domain_sep(&parse32(salt)); + + let mut w = EvmSourceWatcher::connect(rpc, outbox, ds, conf, 0).await.unwrap(); + let msgs = w.poll().await.unwrap(); + println!("polled {}", msgs.len()); + for m in &msgs { + println!(" nonce={} src={} dst={} payload=0x{}", m.nonce, m.src_chain_id, m.dst_chain_id, hex::encode(&m.payload)); + } +} diff --git a/rust-backend/services/bridge-relayer/examples/evm_submit_smoke.rs b/rust-backend/services/bridge-relayer/examples/evm_submit_smoke.rs new file mode 100644 index 00000000..21ac59e2 --- /dev/null +++ b/rust-backend/services/bridge-relayer/examples/evm_submit_smoke.rs @@ -0,0 +1,51 @@ +//! End-to-end check of the EVM destination submitter against a local node. +//! +//! Args: +//! +//! Signs a Sui→EVM message with the group key, submits it via the real +//! `EvmDestSubmitter`, and asserts the Inbox marked it consumed (which only +//! happens if the ECDSA signature verified AND dispatch to the recipient +//! succeeded — the whole tx reverts otherwise). + +use bridge_relayer::evm_submit::EvmDestSubmitter; +use bridge_relayer::relay::DestSubmitter; +use bridge_signer::ThresholdSigner; +use bridge_types::message::{derive_domain_sep, left_pad_address}; +use bridge_types::{chain_id, CrossChainMessage}; + +fn parse32(s: &str) -> [u8; 32] { + hex::decode(s.trim_start_matches("0x")).unwrap().try_into().unwrap() +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + let (rpc, inbox, recipient, relayer_key, group_seed, salt_hex) = + (&a[1], &a[2], &a[3], &a[4], &a[5], &a[6]); + let domain_sep = derive_domain_sep(&parse32(salt_hex)); + + let recipient_addr: [u8; 20] = + hex::decode(recipient.trim_start_matches("0x")).unwrap().try_into().unwrap(); + + // dst = HyperEVM internal id → the signer selects the ECDSA scheme. + let message = CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + 0, + [0xaa; 32], + left_pad_address(recipient_addr), + b"evm-submit-smoke".to_vec(), + ); + + let signer = ThresholdSigner::from_seeds([0x42; 32], parse32(group_seed))?; + let envelope = signer.sign(&message, &domain_sep, 1)?; + let digest = message.digest(&domain_sep); + + let submitter = EvmDestSubmitter::new(rpc, inbox, relayer_key)?; + assert!(!submitter.is_delivered(&digest).await?, "should not be delivered yet"); + submitter.submit(&message, &envelope).await?; + assert!(submitter.is_delivered(&digest).await?, "should be consumed after submit"); + + println!("OK message_hash=0x{} delivered + consumed on EVM Inbox", hex::encode(digest)); + Ok(()) +} diff --git a/rust-backend/services/bridge-relayer/examples/sui_submit_smoke.rs b/rust-backend/services/bridge-relayer/examples/sui_submit_smoke.rs new file mode 100644 index 00000000..89ae06dd --- /dev/null +++ b/rust-backend/services/bridge-relayer/examples/sui_submit_smoke.rs @@ -0,0 +1,39 @@ +//! Exercise the real `SuiDestSubmitter` write path end-to-end against testnet: +//! read `dst_app`'s type on chain → derive the `bridge_receive` target → build +//! the PTB → submit via sui-sdk. Delivers one already-committed EVM→Sui message. +//! +//! Args: +//! + +use bridge_relayer::relay::DestSubmitter; +use bridge_relayer::sui_dest::SuiDestSubmitter; +use bridge_types::{envelope::SCHEME_ED25519, CrossChainMessage, SignatureEnvelope}; + +fn b32(s: &str) -> [u8; 32] { + let mut v = hex::decode(s.trim_start_matches("0x")).unwrap(); + while v.len() < 32 { + v.insert(0, 0); + } + v.try_into().unwrap() +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let a: Vec = std::env::args().collect(); + let (rpc, key, inbox, keys) = (&a[1], &a[2], &a[3], &a[4]); + let src_app = b32(&a[5]); + let dst_app = b32(&a[6]); + let payload = hex::decode(a[7].trim_start_matches("0x")).unwrap(); + let signature = hex::decode(a[8].trim_start_matches("0x")).unwrap(); + let group_pubkey_id: u32 = a[9].parse().unwrap(); + + // HyperEVM(268436454) → Sui(134217728), nonce 1 (the second lock). + let message = CrossChainMessage::new(268436454, 134217728, 1, src_app, dst_app, payload); + let envelope = SignatureEnvelope { scheme_tag: SCHEME_ED25519, group_pubkey_id, signature }; + + let submitter = SuiDestSubmitter::connect(rpc, key, inbox, keys, 200_000_000).await?; + println!("submitting via real SuiDestSubmitter (type-derived bridge_receive)..."); + submitter.submit(&message, &envelope).await?; + println!("OK — delivered to Sui"); + Ok(()) +} diff --git a/rust-backend/services/bridge-relayer/src/config.rs b/rust-backend/services/bridge-relayer/src/config.rs new file mode 100644 index 00000000..b0a969f8 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/config.rs @@ -0,0 +1,72 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use bridge_types::message::derive_domain_sep; +use bridge_types::Bytes32; +use runtime_config::config_load; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + /// Sui JSON-RPC URL of the source chain to watch. + pub source_rpc_url: String, + /// Deployed bridge package id (holds the `events` module + Outbox). + pub bridge_package_id: String, + /// Base URL of the signer node (`bridge-signer-service`). + pub signer_url: String, + /// 32-byte hex per-deployment salt; the digest domain separator is + /// `keccak256("XCHAIN_MSG_V1" || salt)` (spec §2.2). MUST match the value + /// baked into the deployed contracts and the signer node. + pub deployment_salt_hex: String, + /// Seconds between source polls. + #[serde(default = "default_poll_secs")] + pub poll_interval_secs: u64, + + /// EVM destination (HyperEVM). When all three are set the relayer submits to + /// the EVM Inbox; otherwise it dry-runs the destination. + pub evm_rpc_url: Option, + pub evm_inbox_addr: Option, + pub evm_relayer_key: Option, + + /// EVM *source* watcher (HyperEVM → Sui). When set, the relayer also polls + /// this Outbox for committed messages. + pub evm_source_rpc_url: Option, + pub evm_source_outbox_addr: Option, + #[serde(default)] + pub evm_source_confirmations: u64, + #[serde(default)] + pub evm_source_start_block: u64, + + /// Sui destination submitter (EVM → Sui). When set, Sui-destined messages are + /// delivered by calling the app's `bridge_receive` convention. + pub sui_dest_rpc_url: Option, + pub sui_relayer_key: Option, + pub sui_inbox_id: Option, + pub sui_group_key_registry_id: Option, + #[serde(default = "default_sui_gas_budget")] + pub sui_gas_budget: u64, +} + +fn default_sui_gas_budget() -> u64 { + 100_000_000 +} + +fn default_poll_secs() -> u64 { + 5 +} + +impl Config { + pub fn load(path: impl AsRef) -> Result { + config_load::load_toml(path) + } + + /// Derive the digest domain separator from the configured deployment salt. + pub fn domain_sep(&self) -> Result { + let bytes = hex::decode(self.deployment_salt_hex.trim_start_matches("0x")) + .context("decoding deployment_salt_hex")?; + let salt: Bytes32 = bytes + .try_into() + .map_err(|v: Vec| anyhow::anyhow!("deployment_salt must be 32 bytes, got {}", v.len()))?; + Ok(derive_domain_sep(&salt)) + } +} diff --git a/rust-backend/services/bridge-relayer/src/event.rs b/rust-backend/services/bridge-relayer/src/event.rs new file mode 100644 index 00000000..0b07984b --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/event.rs @@ -0,0 +1,174 @@ +//! Reconstruct a canonical [`CrossChainMessage`] from an on-chain +//! `MessageCommitted` event's `parsed_json` (the Move `sui_bridge::events` +//! struct). The decoder re-derives the keccak digest and checks it against the +//! `message_hash` the event carried — a malformed or tampered event is rejected +//! before it ever reaches the signer. +//! +//! Sui renders a Move `vector` as a JSON array of numbers and a `u64` as a +//! string; both forms (plus `0x`-hex for bytes) are accepted for robustness. + +use bridge_types::{Bytes32, CrossChainMessage}; +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum EventDecodeError { + #[error("missing field `{0}`")] + MissingField(&'static str), + #[error("field `{0}` has the wrong shape")] + BadField(&'static str), + #[error("field `{field}` must be {expected} bytes, got {got}")] + BadLength { field: &'static str, expected: usize, got: usize }, + #[error("event message_hash does not match the recomputed digest")] + HashMismatch, +} + +pub fn decode_message_committed( + parsed: &Value, + domain_sep: &Bytes32, +) -> Result { + let src_chain_id = u32_field(parsed, "src_chain_id")?; + let dst_chain_id = u32_field(parsed, "dst_chain_id")?; + let nonce = u64_field(parsed, "nonce")?; + let src_app = bytes32_field(parsed, "src_app")?; + let dst_app = bytes32_field(parsed, "dst_app")?; + let payload = bytes_field(parsed, "payload")?; + let claimed_hash = bytes32_field(parsed, "message_hash")?; + + let message = + CrossChainMessage::new(src_chain_id, dst_chain_id, nonce, src_app, dst_app, payload); + + // The event is untrusted plumbing — verify its hash matches the canonical + // (domain-separated) digest of the fields we just reconstructed. + if message.digest(domain_sep) != claimed_hash { + return Err(EventDecodeError::HashMismatch); + } + Ok(message) +} + +fn field<'a>(v: &'a Value, name: &'static str) -> Result<&'a Value, EventDecodeError> { + v.get(name).ok_or(EventDecodeError::MissingField(name)) +} + +fn u32_field(v: &Value, name: &'static str) -> Result { + let f = field(v, name)?; + let n = match f { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.parse::().ok(), + _ => None, + } + .ok_or(EventDecodeError::BadField(name))?; + u32::try_from(n).map_err(|_| EventDecodeError::BadField(name)) +} + +fn u64_field(v: &Value, name: &'static str) -> Result { + let f = field(v, name)?; + match f { + Value::Number(n) => n.as_u64().ok_or(EventDecodeError::BadField(name)), + Value::String(s) => s.parse::().map_err(|_| EventDecodeError::BadField(name)), + _ => Err(EventDecodeError::BadField(name)), + } +} + +fn bytes_field(v: &Value, name: &'static str) -> Result, EventDecodeError> { + match field(v, name)? { + Value::Array(items) => items + .iter() + .map(|x| { + x.as_u64() + .and_then(|n| u8::try_from(n).ok()) + .ok_or(EventDecodeError::BadField(name)) + }) + .collect(), + Value::String(s) => { + hex::decode(s.trim_start_matches("0x")).map_err(|_| EventDecodeError::BadField(name)) + } + _ => Err(EventDecodeError::BadField(name)), + } +} + +fn bytes32_field(v: &Value, name: &'static str) -> Result { + let bytes = bytes_field(v, name)?; + bytes + .as_slice() + .try_into() + .map_err(|_| EventDecodeError::BadLength { field: name, expected: 32, got: 0 }) + .map(|b: &[u8; 32]| *b) +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_types::chain_id; + use bridge_types::message::derive_domain_sep; + use serde_json::json; + + const TEST_SALT: Bytes32 = [0x01; 32]; + + fn test_domain_sep() -> Bytes32 { + derive_domain_sep(&TEST_SALT) + } + + /// Domain-separated digest of the known vector under TEST_SALT (matches the + /// bridge-types and cross-language parity vectors). + fn known_hash_hex() -> &'static str { + "535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707" + } + + /// Array-of-numbers form (how Sui actually renders `vector`), with the + /// `u64` nonce as a string. Reconstructs the known-vector message. + #[test] + fn decodes_array_form() { + let hash: Vec = hex::decode(known_hash_hex()).unwrap(); + let parsed = json!({ + "outbox_id": "0xabc", + "message_hash": hash, + "src_chain_id": 268_436_454u64, + "dst_chain_id": 134_217_728u64, + "nonce": "7", + "src_app": vec![0xabu8; 32], + "dst_app": vec![0xcdu8; 32], + "payload": b"hello-bridge".to_vec(), + }); + let m = decode_message_committed(&parsed, &test_domain_sep()).unwrap(); + assert_eq!(m.src_chain_id, chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap()); + assert_eq!(m.dst_chain_id, chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap()); + assert_eq!(m.nonce, 7); + assert_eq!(m.payload, b"hello-bridge"); + assert_eq!(hex::encode(m.digest(&test_domain_sep())), known_hash_hex()); + } + + /// `0x`-hex form for byte fields is also accepted. + #[test] + fn decodes_hex_string_form() { + let parsed = json!({ + "message_hash": format!("0x{}", known_hash_hex()), + "src_chain_id": 268_436_454u64, + "dst_chain_id": 134_217_728u64, + "nonce": "7", + "src_app": format!("0x{}", "ab".repeat(32)), + "dst_app": format!("0x{}", "cd".repeat(32)), + "payload": "0x68656c6c6f2d627269646765", + }); + let m = decode_message_committed(&parsed, &test_domain_sep()).unwrap(); + assert_eq!(hex::encode(m.digest(&test_domain_sep())), known_hash_hex()); + } + + /// A message_hash that doesn't match the reconstructed fields is rejected. + #[test] + fn rejects_hash_mismatch() { + let parsed = json!({ + "message_hash": vec![0u8; 32], + "src_chain_id": 268_436_454u64, + "dst_chain_id": 134_217_728u64, + "nonce": "7", + "src_app": vec![0xabu8; 32], + "dst_app": vec![0xcdu8; 32], + "payload": b"hello-bridge".to_vec(), + }); + assert_eq!( + decode_message_committed(&parsed, &test_domain_sep()), + Err(EventDecodeError::HashMismatch) + ); + } +} diff --git a/rust-backend/services/bridge-relayer/src/evm_source.rs b/rust-backend/services/bridge-relayer/src/evm_source.rs new file mode 100644 index 00000000..cb7f80d7 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/evm_source.rs @@ -0,0 +1,114 @@ +//! EVM source watcher (the HyperEVM → Sui direction). Polls the registered +//! Outbox for `MessageCommitted` logs past a confirmation depth, reconstructs +//! each `CrossChainMessage`, and hash-checks it against the indexed +//! `messageHash` topic before handing it to the relay loop. +//! +//! Uses alloy (already a relayer dep) so the non-indexed event fields ABI-decode +//! cleanly, rather than hand-rolling ABI parsing over raw JSON-RPC. + +use alloy::primitives::Address; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use alloy::rpc::types::Filter; +use alloy::sol; +use alloy::sol_types::SolEvent; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use bridge_types::{Bytes32, CrossChainMessage}; +use tracing::warn; + +use crate::relay::SourceWatcher; + +sol! { + #[sol(rpc)] + event MessageCommitted( + bytes32 indexed messageHash, + uint32 srcChainId, + uint32 dstChainId, + uint64 nonce, + bytes32 srcApp, + bytes32 dstApp, + bytes payload + ); +} + +pub struct EvmSourceWatcher { + provider: DynProvider, + outbox: Address, + domain_sep: Bytes32, + /// Confirmation depth before a committed message is considered final (§4). + confirmations: u64, + /// Next block to scan from (advances past each polled, finalized range). + from_block: u64, +} + +impl EvmSourceWatcher { + /// Connect a read-only provider and start scanning at `start_block`. + pub async fn connect( + rpc_url: &str, + outbox: &str, + domain_sep: Bytes32, + confirmations: u64, + start_block: u64, + ) -> Result { + let provider = ProviderBuilder::new() + .connect_http(rpc_url.parse().context("parsing evm rpc url")?) + .erased(); + let chain_id = provider.get_chain_id().await.context("evm get_chain_id")?; + tracing::info!(rpc_url, chain_id, outbox, "connected to EVM source RPC"); + Ok(Self { + provider, + outbox: outbox.parse().context("parsing outbox address")?, + domain_sep, + confirmations, + from_block: start_block, + }) + } +} + +#[async_trait] +impl SourceWatcher for EvmSourceWatcher { + async fn poll(&mut self) -> Result> { + let latest = self.provider.get_block_number().await.context("evm get_block_number")?; + // Only scan blocks with at least `confirmations` on top of them. + let safe_to = latest.saturating_sub(self.confirmations); + if safe_to < self.from_block { + return Ok(vec![]); + } + + let filter = Filter::new() + .address(self.outbox) + .event_signature(MessageCommitted::SIGNATURE_HASH) + .from_block(self.from_block) + .to_block(safe_to); + let logs = self.provider.get_logs(&filter).await.context("evm get_logs")?; + + let mut out = Vec::new(); + for log in logs { + let ev = match log.log_decode::() { + Ok(d) => d.inner.data, + Err(e) => { + warn!(error = %e, "skipping undecodable MessageCommitted log"); + continue; + } + }; + let msg = CrossChainMessage::new( + ev.srcChainId, + ev.dstChainId, + ev.nonce, + ev.srcApp.0, + ev.dstApp.0, + ev.payload.to_vec(), + ); + // Untrusted plumbing: the reconstructed message must hash to the + // indexed messageHash the Outbox committed. + if msg.digest(&self.domain_sep) != ev.messageHash.0 { + warn!("skipping MessageCommitted log whose fields do not match its messageHash"); + continue; + } + out.push(msg); + } + + self.from_block = safe_to + 1; + Ok(out) + } +} diff --git a/rust-backend/services/bridge-relayer/src/evm_submit.rs b/rust-backend/services/bridge-relayer/src/evm_submit.rs new file mode 100644 index 00000000..d77a9186 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/evm_submit.rs @@ -0,0 +1,100 @@ +//! EVM destination submitter: delivers a verified message to a HyperEVM +//! `Inbox.receiveMessage(message, envelope)` (the Sui → EVM direction). The +//! struct ABI must match `Inbox.sol` / `Message.sol` exactly. + +use alloy::network::EthereumWallet; +use alloy::primitives::{Address, Bytes, FixedBytes}; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use alloy::signers::local::PrivateKeySigner; +use alloy::sol; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use bridge_types::{Bytes32, CrossChainMessage, SignatureEnvelope}; + +use crate::relay::DestSubmitter; + +sol! { + #[sol(rpc)] + contract IInbox { + struct CrossChainMessage { + uint8 version; + uint32 srcChainId; + uint32 dstChainId; + uint64 nonce; + bytes32 srcApp; + bytes32 dstApp; + bytes payload; + } + struct SignatureEnvelope { + uint8 schemeTag; + uint32 groupPubkeyId; + bytes signature; + } + function receiveMessage(CrossChainMessage message, SignatureEnvelope envelope) external; + function consumed(bytes32 messageHash) external view returns (bool); + } +} + +pub struct EvmDestSubmitter { + provider: DynProvider, + inbox: Address, +} + +impl EvmDestSubmitter { + pub fn new(rpc_url: &str, inbox: &str, relayer_key: &str) -> Result { + let signer: PrivateKeySigner = + relayer_key.trim_start_matches("0x").parse().context("parsing relayer key")?; + let provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(signer)) + .connect_http(rpc_url.parse().context("parsing evm_rpc_url")?) + .erased(); + Ok(Self { provider, inbox: inbox.parse().context("parsing evm_inbox_addr")? }) + } + + fn to_sol_message(m: &CrossChainMessage) -> IInbox::CrossChainMessage { + IInbox::CrossChainMessage { + version: m.version, + srcChainId: m.src_chain_id, + dstChainId: m.dst_chain_id, + nonce: m.nonce, + srcApp: FixedBytes::from(m.src_app), + dstApp: FixedBytes::from(m.dst_app), + payload: Bytes::from(m.payload.clone()), + } + } + + fn to_sol_envelope(e: &SignatureEnvelope) -> IInbox::SignatureEnvelope { + IInbox::SignatureEnvelope { + schemeTag: e.scheme_tag, + groupPubkeyId: e.group_pubkey_id, + signature: Bytes::from(e.signature.clone()), + } + } +} + +#[async_trait] +impl DestSubmitter for EvmDestSubmitter { + async fn is_delivered(&self, digest: &Bytes32) -> Result { + let inbox = IInbox::new(self.inbox, &self.provider); + let consumed = inbox.consumed(FixedBytes::from(*digest)).call().await?; + Ok(consumed) + } + + async fn submit( + &self, + message: &CrossChainMessage, + envelope: &SignatureEnvelope, + ) -> Result<()> { + let inbox = IInbox::new(self.inbox, &self.provider); + let pending = inbox + .receiveMessage(Self::to_sol_message(message), Self::to_sol_envelope(envelope)) + .send() + .await + .context("sending receiveMessage")?; + let receipt = pending.get_receipt().await.context("awaiting receipt")?; + if !receipt.status() { + bail!("receiveMessage reverted (tx {:?})", receipt.transaction_hash); + } + Ok(()) + } +} diff --git a/rust-backend/services/bridge-relayer/src/lib.rs b/rust-backend/services/bridge-relayer/src/lib.rs new file mode 100644 index 00000000..0de2724b --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/lib.rs @@ -0,0 +1,26 @@ +//! Untrusted relayer (bridge-spec.md §2, Layer 3): watch a source Outbox → +//! request an aggregated signature from the signer node → submit the +//! self-verifying message to the destination Inbox. + +use std::path::PathBuf; + +use clap::Parser; + +pub mod config; +pub mod event; +pub mod evm_source; +pub mod evm_submit; +pub mod relay; +pub mod signer_client; +pub mod submit; +pub mod sui_dest; +pub mod sui_source; + +pub use config::Config; + +#[derive(Debug, Parser)] +#[command(name = "bridge-relayer", about = "Layer 1 message relayer (M1)")] +pub struct Cli { + #[arg(long, default_value = "config.toml")] + pub config: PathBuf, +} diff --git a/rust-backend/services/bridge-relayer/src/main.rs b/rust-backend/services/bridge-relayer/src/main.rs new file mode 100644 index 00000000..6702f3e8 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/main.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use tracing::{error, info}; + +use bridge_relayer::evm_source::EvmSourceWatcher; +use bridge_relayer::evm_submit::EvmDestSubmitter; +use bridge_relayer::relay::{relay_once, Router, SourceWatcher}; +use bridge_relayer::signer_client::HttpSigner; +use bridge_relayer::sui_dest::SuiDestSubmitter; +use bridge_relayer::sui_source::SuiSourceWatcher; +use bridge_relayer::{Cli, Config}; + +#[tokio::main] +async fn main() -> Result<()> { + let _obs = observability::init("bridge-relayer"); + + let cli = Cli::parse(); + let cfg = Config::load(&cli.config) + .with_context(|| format!("loading config from {}", cli.config.display()))?; + let domain_sep = cfg.domain_sep().context("deriving domain separator")?; + + // --- destination router (which direction goes where) --- + let mut router = Router::default(); + match (&cfg.evm_rpc_url, &cfg.evm_inbox_addr, &cfg.evm_relayer_key) { + (Some(rpc), Some(inbox), Some(key)) => { + info!(evm_inbox = %inbox, "EVM destination enabled"); + router = router.with_evm(Box::new(EvmDestSubmitter::new(rpc, inbox, key)?)); + } + _ => info!("EVM destination not configured"), + } + match ( + &cfg.sui_dest_rpc_url, + &cfg.sui_relayer_key, + &cfg.sui_inbox_id, + &cfg.sui_group_key_registry_id, + ) { + (Some(rpc), Some(key), Some(inbox), Some(keys)) => { + info!("Sui destination enabled (type-derived bridge_receive)"); + let s = SuiDestSubmitter::connect(rpc, key, inbox, keys, cfg.sui_gas_budget) + .await + .context("connecting Sui destination submitter")?; + router = router.with_sui(Box::new(s)); + } + _ => info!("Sui destination not configured"), + } + let router = Arc::new(router); + let signer = Arc::new(HttpSigner::new(&cfg.signer_url)); + let interval = Duration::from_secs(cfg.poll_interval_secs); + + // --- source watchers (each direction's origin) --- + let mut tasks = Vec::new(); + + let sui_watcher: Box = + Box::new(SuiSourceWatcher::connect(&cfg.source_rpc_url, &cfg.bridge_package_id, domain_sep).await?); + tasks.push(spawn_watcher("sui-source", sui_watcher, domain_sep, signer.clone(), router.clone(), interval)); + + if let (Some(rpc), Some(outbox)) = (&cfg.evm_source_rpc_url, &cfg.evm_source_outbox_addr) { + info!(evm_outbox = %outbox, "EVM source watcher enabled"); + let w = EvmSourceWatcher::connect( + rpc, + outbox, + domain_sep, + cfg.evm_source_confirmations, + cfg.evm_source_start_block, + ) + .await + .context("connecting EVM source watcher")?; + tasks.push(spawn_watcher("evm-source", Box::new(w), domain_sep, signer.clone(), router.clone(), interval)); + } + + info!(sources = tasks.len(), "bridge-relayer started"); + // If any watcher loop exits, the process exits (they loop forever normally). + futures_join(tasks).await; + Ok(()) +} + +fn spawn_watcher( + name: &'static str, + mut watcher: Box, + domain_sep: bridge_types::Bytes32, + signer: Arc, + router: Arc, + interval: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + match relay_once(watcher.as_mut(), &domain_sep, signer.as_ref(), router.as_ref()).await { + Ok(n) if n > 0 => info!(source = name, delivered = n, "poll complete"), + Ok(_) => {} + Err(e) => error!(source = name, error = %format!("{e:#}"), "poll failed, retrying"), + } + tokio::time::sleep(interval).await; + } + }) +} + +async fn futures_join(tasks: Vec>) { + for t in tasks { + let _ = t.await; + } +} diff --git a/rust-backend/services/bridge-relayer/src/relay.rs b/rust-backend/services/bridge-relayer/src/relay.rs new file mode 100644 index 00000000..dc12955d --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/relay.rs @@ -0,0 +1,229 @@ +//! Relay orchestration. For each committed source message: skip if the +//! destination already delivered it (gas saver — the on-chain `consumed` set is +//! the real guard), else fetch a signature and submit. The relayer is +//! untrusted; correctness never depends on it. + +use anyhow::{bail, Result}; +use async_trait::async_trait; +use bridge_types::{chain_id, Bytes32, CrossChainMessage, SignatureEnvelope}; +use tracing::{info, warn}; + +use crate::signer_client::RemoteSigner; + +/// Yields newly-committed messages from a source chain's Outbox. +#[async_trait] +pub trait SourceWatcher: Send { + async fn poll(&mut self) -> Result>; +} + +/// Submits a verified message to a destination chain's Inbox. +#[async_trait] +pub trait DestSubmitter: Send + Sync { + /// Whether the destination Inbox has already consumed this digest. + async fn is_delivered(&self, digest: &Bytes32) -> Result; + async fn submit(&self, message: &CrossChainMessage, envelope: &SignatureEnvelope) + -> Result<()>; +} + +#[derive(Debug, PartialEq, Eq)] +pub enum RelayOutcome { + Delivered, + AlreadyDelivered, +} + +/// Routes a message to the submitter for its destination chain family. One +/// relayer process relays every direction: EVM destinations via the generic EVM +/// submitter, Sui destinations via the type-derived Sui submitter. +#[derive(Default)] +pub struct Router { + evm: Option>, + sui: Option>, +} + +impl Router { + pub fn with_evm(mut self, s: Box) -> Self { + self.evm = Some(s); + self + } + pub fn with_sui(mut self, s: Box) -> Self { + self.sui = Some(s); + self + } + + /// Pick the submitter for a message's destination, by family. + pub fn for_dst(&self, dst_chain_id: u32) -> Result<&dyn DestSubmitter> { + let family = chain_id::family(dst_chain_id); + let slot = match family { + chain_id::FAMILY_EVM => &self.evm, + chain_id::FAMILY_SUI => &self.sui, + other => bail!("no submitter configured for destination family {other}"), + }; + slot.as_deref() + .ok_or_else(|| anyhow::anyhow!("no submitter configured for destination family {family}")) + } +} + +pub async fn relay_message( + message: &CrossChainMessage, + domain_sep: &Bytes32, + signer: &dyn RemoteSigner, + submitter: &dyn DestSubmitter, +) -> Result { + let digest = message.digest(domain_sep); + if submitter.is_delivered(&digest).await? { + return Ok(RelayOutcome::AlreadyDelivered); + } + let envelope = signer.sign(message).await?; + submitter.submit(message, &envelope).await?; + Ok(RelayOutcome::Delivered) +} + +/// One poll → relay pass over a source watcher, routing each message to the +/// submitter for its destination family. Returns the number newly delivered. A +/// single message failing to relay is logged and skipped, not fatal. +pub async fn relay_once( + watcher: &mut dyn SourceWatcher, + domain_sep: &Bytes32, + signer: &dyn RemoteSigner, + router: &Router, +) -> Result { + let mut delivered = 0; + for message in watcher.poll().await? { + let digest = hex::encode(message.digest(domain_sep)); + let submitter = match router.for_dst(message.dst_chain_id) { + Ok(s) => s, + Err(e) => { + warn!(digest = %digest, dst = message.dst_chain_id, error = %e, "no route, skipping"); + continue; + } + }; + match relay_message(&message, domain_sep, signer, submitter).await { + Ok(RelayOutcome::Delivered) => { + info!(digest = %digest, nonce = message.nonce, "relayed"); + delivered += 1; + } + Ok(RelayOutcome::AlreadyDelivered) => { + info!(digest = %digest, "already delivered, skipping"); + } + // Tx-submission failures carry an alert_id per .claude/tx-alerting.md. + // Losing a delivery race (already consumed on arrival) is benign and + // surfaces as AlreadyDelivered above, not here. + Err(e) => tracing::error!( + alert_id = "tx-failed-bridge-relay", + digest = %digest, + dst = message.dst_chain_id, + error = %format!("{e:#}"), + "relay submission failed, will retry next poll" + ), + } + } + Ok(delivered) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::sync::Mutex; + + use bridge_signer::ThresholdSigner; + use bridge_types::chain_id; + use bridge_types::message::derive_domain_sep; + + const TEST_SALT: Bytes32 = [0x01; 32]; + + struct LocalSigner { + signer: ThresholdSigner, + domain_sep: Bytes32, + } + #[async_trait] + impl RemoteSigner for LocalSigner { + async fn sign(&self, m: &CrossChainMessage) -> Result { + Ok(self.signer.sign(m, &self.domain_sep, 1)?) + } + } + + struct RecordingSubmitter { + domain_sep: Bytes32, + delivered: Mutex>, + submissions: Mutex>, + } + #[async_trait] + impl DestSubmitter for RecordingSubmitter { + async fn is_delivered(&self, digest: &Bytes32) -> Result { + Ok(self.delivered.lock().unwrap().contains(digest)) + } + async fn submit(&self, m: &CrossChainMessage, e: &SignatureEnvelope) -> Result<()> { + self.delivered.lock().unwrap().insert(m.digest(&self.domain_sep)); + self.submissions.lock().unwrap().push((m.clone(), e.clone())); + Ok(()) + } + } + + fn vector_message() -> CrossChainMessage { + CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + 7, + [0xab; 32], + [0xcd; 32], + b"hello-bridge".to_vec(), + ) + } + + const SUI_ID: u32 = 134_217_728; + const HYPER_ID: u32 = 268_436_454; + + #[test] + fn router_dispatches_by_family() { + let router = Router::default() + .with_evm(Box::new(RecordingSubmitter { + domain_sep: [0; 32], + delivered: Mutex::default(), + submissions: Mutex::default(), + })) + .with_sui(Box::new(RecordingSubmitter { + domain_sep: [0; 32], + delivered: Mutex::default(), + submissions: Mutex::default(), + })); + // EVM + Sui destinations both resolve; an unconfigured family errors. + assert!(router.for_dst(HYPER_ID).is_ok()); + assert!(router.for_dst(SUI_ID).is_ok()); + assert!(Router::default().for_dst(HYPER_ID).is_err()); // nothing configured + } + + #[tokio::test] + async fn relays_then_dedups() { + let ds = derive_domain_sep(&TEST_SALT); + let signer = LocalSigner { + signer: ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(), + domain_sep: ds, + }; + let submitter = + RecordingSubmitter { domain_sep: ds, delivered: Mutex::default(), submissions: Mutex::default() }; + let m = vector_message(); + + // First pass delivers and submits the on-chain-valid signature. + assert_eq!( + relay_message(&m, &ds, &signer, &submitter).await.unwrap(), + RelayOutcome::Delivered + ); + let subs = submitter.submissions.lock().unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].1.scheme_tag, bridge_types::envelope::SCHEME_ED25519); + assert_eq!( + hex::encode(&subs[0].1.signature), + "12bc85a949906a86bdea305aa6bc32ef704e77de62ea5fb65a3df3a39902e533\ +98ca95da28a3c34aa8187edcf8f6936330c94016e1e1c4d3f2f7b80027190001" + ); + drop(subs); + + // Second pass sees it already delivered. + assert_eq!( + relay_message(&m, &ds, &signer, &submitter).await.unwrap(), + RelayOutcome::AlreadyDelivered + ); + assert_eq!(submitter.submissions.lock().unwrap().len(), 1); + } +} diff --git a/rust-backend/services/bridge-relayer/src/signer_client.rs b/rust-backend/services/bridge-relayer/src/signer_client.rs new file mode 100644 index 00000000..f8585f08 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/signer_client.rs @@ -0,0 +1,103 @@ +//! Client for the signer node's async signing API (bridge-spec.md §5.3): +//! `POST /sign_requests` to submit, then poll `GET /sign_requests/{hash}` until +//! the session is `signed`. The submit-then-poll is hidden behind the same +//! [`RemoteSigner::sign`] method, so the relay loop is unchanged and the M3 MPC +//! turn-on (where signing genuinely takes multiple rounds) needs no client edit. + +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use bridge_types::{CrossChainMessage, SignatureEnvelope}; +use serde::{Deserialize, Serialize}; + +#[async_trait] +pub trait RemoteSigner: Send + Sync { + /// Request an aggregated signature envelope for `message`. + async fn sign(&self, message: &CrossChainMessage) -> Result; +} + +pub struct HttpSigner { + base_url: String, + http: reqwest::Client, + poll_interval: Duration, + poll_attempts: u32, +} + +impl HttpSigner { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + poll_interval: Duration::from_millis(500), + poll_attempts: 60, // ~30s ceiling; M1 completes on the first poll + } + } +} + +#[derive(Serialize)] +struct SignRequestBody<'a> { + message: &'a CrossChainMessage, +} + +#[derive(Deserialize)] +struct SignRequestResponse { + message_hash: String, + status: String, + envelope: Option, +} + +#[async_trait] +impl RemoteSigner for HttpSigner { + async fn sign(&self, message: &CrossChainMessage) -> Result { + // Submit (idempotent per hash). A 202 may already carry the signature. + let url = format!("{}/sign_requests", self.base_url); + let submitted: SignRequestResponse = self + .http + .post(&url) + .json(&SignRequestBody { message }) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status() + .context("signer rejected the request")? + .json() + .await + .context("decoding submit response")?; + if let Some(env) = signed_envelope(&submitted)? { + return Ok(env); + } + + // Otherwise poll the session to completion. + let poll_url = format!("{}/sign_requests/{}", self.base_url, submitted.message_hash); + for _ in 0..self.poll_attempts { + tokio::time::sleep(self.poll_interval).await; + let polled: SignRequestResponse = self + .http + .get(&poll_url) + .send() + .await + .with_context(|| format!("GET {poll_url}"))? + .error_for_status() + .context("polling signer session")? + .json() + .await + .context("decoding poll response")?; + if let Some(env) = signed_envelope(&polled)? { + return Ok(env); + } + } + bail!("signer session {} did not complete within the poll budget", submitted.message_hash); + } +} + +/// Extract the envelope from a `signed` response, or None if still `pending`. +fn signed_envelope(r: &SignRequestResponse) -> Result> { + match r.status.as_str() { + "signed" => Ok(Some( + r.envelope.clone().context("signer reported signed but returned no envelope")?, + )), + "pending" => Ok(None), + other => bail!("unexpected signer session status: {other}"), + } +} diff --git a/rust-backend/services/bridge-relayer/src/submit.rs b/rust-backend/services/bridge-relayer/src/submit.rs new file mode 100644 index 00000000..cfea41cb --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/submit.rs @@ -0,0 +1,42 @@ +//! Destination submitters. +//! +//! M1 ships only [`DryRunSubmitter`]: it logs the message + signature it *would* +//! submit, so the relayer can run live against a deployed source Outbox and +//! demonstrate the watch → sign path. The real submitters are the next step: +//! - EVM Inbox: needs an EVM client (none vendored yet). +//! - Sui Inbox: needs the Layer-2 Locker to consume the delivered hot potato. + +use anyhow::Result; +use async_trait::async_trait; +use bridge_types::{Bytes32, CrossChainMessage, SignatureEnvelope}; +use tracing::info; + +use crate::relay::DestSubmitter; + +pub struct DryRunSubmitter { + pub domain_sep: Bytes32, +} + +#[async_trait] +impl DestSubmitter for DryRunSubmitter { + async fn is_delivered(&self, _digest: &Bytes32) -> Result { + Ok(false) + } + + async fn submit( + &self, + message: &CrossChainMessage, + envelope: &SignatureEnvelope, + ) -> Result<()> { + info!( + dst_chain_id = message.dst_chain_id, + nonce = message.nonce, + scheme_tag = envelope.scheme_tag, + group_pubkey_id = envelope.group_pubkey_id, + message_hash = %format!("0x{}", hex::encode(message.digest(&self.domain_sep))), + signature = %format!("0x{}", hex::encode(&envelope.signature)), + "DRY RUN — would submit to destination Inbox (real adapter pending)" + ); + Ok(()) + } +} diff --git a/rust-backend/services/bridge-relayer/src/sui_dest.rs b/rust-backend/services/bridge-relayer/src/sui_dest.rs new file mode 100644 index 00000000..ac9782b0 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/sui_dest.rs @@ -0,0 +1,231 @@ +//! Sui destination submitter (the EVM → Sui direction). +//! +//! Move has no dynamic dispatch, so the Inbox can't call an arbitrary app; the +//! app must be the entry point (relayer-dispatch-design). This submitter is +//! *generic* over any app that follows the standard `bridge_receive` convention: +//! it reads the `dst_app` object's type on chain to learn `(package, module, +//! type args)`, then builds a single `MoveCall` to `PKG::MODULE::bridge_receive` +//! passing the L1 shared objects + the message/envelope as BCS `vector` +//! args. No per-app relayer configuration. +//! +//! [`parse_dispatch`] is the pure, unit-tested core (type-string → call target); +//! [`SuiDestSubmitter`] wraps it with the on-chain read + PTB submission. + +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use bridge_types::{Bytes32, CrossChainMessage, SignatureEnvelope}; +use sui_json_rpc_types::SuiObjectDataOptions; +use sui_sdk::{SuiClient, SuiClientBuilder}; +use sui_types::base_types::ObjectID; +use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use sui_types::{Identifier, TypeTag}; + +use sui_tx::sui_client::Signer; +use sui_tx::tx::{shared_object_arg, submit_ptb}; + +use crate::relay::DestSubmitter; + +/// The standard convention entry every Locker-style app exposes. +pub const RECEIVE_FUNCTION: &str = "bridge_receive"; +/// The Sui system `Clock` object id (0x6), a `bridge_receive` argument. +pub const CLOCK_OBJECT_ID: &str = "0x0000000000000000000000000000000000000000000000000000000000000006"; + +/// The MoveCall target derived from a `dst_app` object's type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dispatch { + /// Defining package, e.g. `0xPKG`. + pub package: String, + /// Module, e.g. `locker`. + pub module: String, + /// Type arguments (the app's generic params), e.g. the wrapped coin type. + pub type_args: Vec, +} + +/// Derive the `bridge_receive` call target from a Sui object type string like +/// `0xPKG::locker::Locker<0xTOK::coin::COIN>`. The package + module come from the +/// object's own type, the type args are its generics — so a standard app needs +/// zero relayer config (its on-chain type *is* the dispatch descriptor). +pub fn parse_dispatch(object_type: &str) -> Result { + let s = object_type.trim(); + // Split off the optional generic parameter list. + let (base, type_args) = match s.find('<') { + Some(open) => { + if !s.ends_with('>') { + bail!("malformed object type (unbalanced generics): {s}"); + } + (&s[..open], split_top_level(&s[open + 1..s.len() - 1])?) + } + None => (s, Vec::new()), + }; + + let parts: Vec<&str> = base.split("::").collect(); + if parts.len() != 3 { + bail!("expected `PKG::module::Struct`, got {base:?}"); + } + let package = parts[0].to_string(); + if !package.starts_with("0x") || package.len() < 3 { + bail!("bad package address in type: {package:?}"); + } + Ok(Dispatch { package, module: parts[1].to_string(), type_args }) +} + +/// Generic Sui destination submitter. Resolves the call target from the +/// `dst_app` object's on-chain type, then submits a single `bridge_receive` +/// MoveCall. Works for any app following the convention with zero per-app config. +pub struct SuiDestSubmitter { + client: SuiClient, + signer: Signer, + inbox_id: ObjectID, + keys_id: ObjectID, + gas_budget: u64, +} + +impl SuiDestSubmitter { + pub async fn connect( + rpc_url: &str, + sui_key: &str, + inbox_id: &str, + keys_id: &str, + gas_budget: u64, + ) -> Result { + let client = SuiClientBuilder::default() + .build(rpc_url) + .await + .context("building Sui client for the destination submitter")?; + Ok(Self { + client, + signer: Signer::from_string(sui_key).context("loading relayer Sui key")?, + inbox_id: inbox_id.parse().context("parsing inbox object id")?, + keys_id: keys_id.parse().context("parsing group-key registry id")?, + gas_budget, + }) + } + + /// Read `dst_app`'s type on chain and derive its `bridge_receive` target. + async fn resolve(&self, dst_app: ObjectID) -> Result { + let resp = self + .client + .read_api() + .get_object_with_options(dst_app, SuiObjectDataOptions::new().with_type()) + .await + .context("reading dst_app object type")?; + let ty = resp + .data + .and_then(|d| d.type_) + .ok_or_else(|| anyhow!("dst_app {dst_app} has no type (not an object?)"))?; + parse_dispatch(&ty.to_string()) + } +} + +#[async_trait] +impl DestSubmitter for SuiDestSubmitter { + /// Best-effort: the on-chain `consumed` set in `inbox::receive` is the real + /// exactly-once guard (a re-delivery simply aborts). A devInspect-based + /// `is_consumed` check to pre-skip re-deliveries is a follow-up; returning + /// false here is always correct, just not gas-optimal on a delivery race. + async fn is_delivered(&self, _digest: &Bytes32) -> Result { + Ok(false) + } + + async fn submit(&self, message: &CrossChainMessage, envelope: &SignatureEnvelope) -> Result<()> { + let dst_app = ObjectID::from_bytes(message.dst_app) + .map_err(|e| anyhow!("dst_app is not a valid object id: {e}"))?; + let dispatch = self.resolve(dst_app).await?; + + let package: ObjectID = dispatch.package.parse().context("parsing dispatch package id")?; + let module = Identifier::new(dispatch.module).context("invalid module identifier")?; + let function = Identifier::new(RECEIVE_FUNCTION).expect("valid identifier"); + let type_args: Vec = dispatch + .type_args + .iter() + .map(|t| sui_types::parse_sui_type_tag(t).with_context(|| format!("parsing type arg {t}"))) + .collect::>()?; + + // Resolve shared-object args (initial_shared_version fetched per read). + let inbox = shared_object_arg(&self.client, self.inbox_id, true).await?; + let keys = shared_object_arg(&self.client, self.keys_id, false).await?; + let app = shared_object_arg(&self.client, dst_app, true).await?; + let clock = shared_object_arg(&self.client, CLOCK_OBJECT_ID.parse().unwrap(), false).await?; + + let mut pt = ProgrammableTransactionBuilder::new(); + let a_inbox = pt.obj(inbox)?; + let a_keys = pt.obj(keys)?; + let a_app = pt.obj(app)?; + // message/envelope as plain `vector` pure args (decoded via from_bcs). + let a_msg = pt.pure(message.to_move_bcs())?; + let a_env = pt.pure(envelope.to_move_bcs())?; + let a_clock = pt.obj(clock)?; + pt.programmable_move_call( + package, + module, + function, + type_args, + vec![a_inbox, a_keys, a_app, a_msg, a_env, a_clock], + ); + + submit_ptb(&self.client, &self.signer, pt, self.gas_budget, "bridge_receive").await?; + Ok(()) + } +} + +/// Split a generic argument list on top-level commas (respecting nested `<>`). +fn split_top_level(inner: &str) -> Result> { + let mut args = Vec::new(); + let mut depth = 0i32; + let mut start = 0usize; + for (i, c) in inner.char_indices() { + match c { + '<' => depth += 1, + '>' => depth -= 1, + ',' if depth == 0 => { + args.push(inner[start..i].trim().to_string()); + start = i + 1; + } + _ => {} + } + } + if depth != 0 { + return Err(anyhow!("unbalanced generics in type args: {inner}")); + } + let last = inner[start..].trim(); + if !last.is_empty() { + args.push(last.to_string()); + } + Ok(args) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_generic_locker_type() { + let d = parse_dispatch( + "0xabc::locker::Locker<0xdef::wtbtc::WTBTC>", + ) + .unwrap(); + assert_eq!(d.package, "0xabc"); + assert_eq!(d.module, "locker"); + assert_eq!(d.type_args, vec!["0xdef::wtbtc::WTBTC".to_string()]); + } + + #[test] + fn parses_nested_generics() { + let d = parse_dispatch("0x1::m::S<0x2::a::A<0x3::b::B>, 0x4::c::C>").unwrap(); + assert_eq!(d.type_args, vec!["0x2::a::A<0x3::b::B>".to_string(), "0x4::c::C".to_string()]); + } + + #[test] + fn parses_non_generic_type() { + let d = parse_dispatch("0xabc::locker::Locker").unwrap(); + assert_eq!(d.module, "locker"); + assert!(d.type_args.is_empty()); + } + + #[test] + fn rejects_malformed() { + assert!(parse_dispatch("0xabc::locker").is_err()); // too few segments + assert!(parse_dispatch("notaddr::m::S").is_err()); // no 0x + assert!(parse_dispatch("0x1::m::S<0x2::a::A").is_err()); // unbalanced + } +} diff --git a/rust-backend/services/bridge-relayer/src/sui_source.rs b/rust-backend/services/bridge-relayer/src/sui_source.rs new file mode 100644 index 00000000..27fec8f7 --- /dev/null +++ b/rust-backend/services/bridge-relayer/src/sui_source.rs @@ -0,0 +1,119 @@ +//! Real Sui source watcher over raw JSON-RPC (`suix_queryEvents`) via reqwest. +//! +//! We talk JSON-RPC directly rather than through `sui-sdk` for a lighter +//! dependency on the read path. (An earlier comment here claimed the SDK's +//! jsonrpsee client stalls on `rpc.discover` and that reqwest hangs against +//! public fullnodes — re-tested 2026-07-01, neither reproduces: curl, reqwest, +//! and sui-sdk all reach `fullnode.testnet.sui.io` in ~0.2–0.3s. Raw reqwest is +//! kept for simplicity, not because sui-sdk is broken.) +//! +//! Sui has fast deterministic finality — events returned by a fullnode are from +//! finalized checkpoints — so no extra confirmation gate is needed (§4). + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use bridge_types::{Bytes32, CrossChainMessage}; +use serde_json::{json, Value}; +use tracing::warn; + +use crate::event::decode_message_committed; +use crate::relay::SourceWatcher; + +const EVENTS_MODULE: &str = "events"; +const COMMITTED_SUFFIX: &str = "::events::MessageCommitted"; + +pub struct SuiSourceWatcher { + http: reqwest::Client, + rpc_url: String, + package: String, + /// Digest domain separator (spec §2.2) used to verify each event's hash. + domain_sep: Bytes32, + /// JSON-RPC `EventID` cursor ({txDigest, eventSeq}) or null for the start. + cursor: Value, + page_limit: u64, +} + +impl SuiSourceWatcher { + /// Build the watcher and sanity-check the RPC (fails fast on a bad URL). + pub async fn connect(rpc_url: &str, package: &str, domain_sep: Bytes32) -> Result { + // Bind egress to IPv4: some hosts' IPv6 path to proxied fullnodes + // black-holes the response (connects, then never replies). + let http = reqwest::Client::builder() + .local_address(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)) + .http1_only() + .build() + .context("building reqwest client")?; + let watcher = Self { + http, + rpc_url: rpc_url.to_string(), + package: package.to_string(), + domain_sep, + cursor: Value::Null, + page_limit: 50, + }; + let id: String = watcher + .rpc("sui_getChainIdentifier", json!([])) + .await + .context("sui_getChainIdentifier")? + .as_str() + .unwrap_or_default() + .to_string(); + tracing::info!(rpc_url, chain_identifier = %id, "connected to Sui RPC"); + Ok(watcher) + } + + async fn rpc(&self, method: &str, params: Value) -> Result { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let resp: Value = self + .http + .post(&self.rpc_url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {method}"))? + .error_for_status()? + .json() + .await + .context("decoding JSON-RPC response")?; + if let Some(err) = resp.get("error") { + return Err(anyhow!("JSON-RPC error from {method}: {err}")); + } + resp.get("result") + .cloned() + .ok_or_else(|| anyhow!("JSON-RPC response missing result for {method}")) + } +} + +#[async_trait] +impl SourceWatcher for SuiSourceWatcher { + async fn poll(&mut self) -> Result> { + let mut out = Vec::new(); + loop { + // MoveEventModule matches by the module that DEFINES the event type + // (`events`), regardless of which module emitted it. MoveModule would + // match the emitting module instead and miss these events. + let filter = json!({ "MoveEventModule": { "package": self.package, "module": EVENTS_MODULE } }); + let params = json!([filter, self.cursor, self.page_limit, false]); + let result = self.rpc("suix_queryEvents", params).await?; + + for ev in result["data"].as_array().cloned().unwrap_or_default() { + let ty = ev["type"].as_str().unwrap_or_default(); + if ty.ends_with(COMMITTED_SUFFIX) { + match decode_message_committed(&ev["parsedJson"], &self.domain_sep) { + Ok(m) => out.push(m), + Err(e) => { + warn!(error = %e, "skipping undecodable MessageCommitted event") + } + } + } + } + + // Advance the cursor so the next poll only sees newer events. + self.cursor = result["nextCursor"].clone(); + if !result["hasNextPage"].as_bool().unwrap_or(false) { + break; + } + } + Ok(out) + } +} diff --git a/rust-backend/services/bridge-signer-service/Cargo.toml b/rust-backend/services/bridge-signer-service/Cargo.toml new file mode 100644 index 00000000..369799c0 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "bridge-signer-service" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "bridge-signer-service" +path = "src/main.rs" + +# The signer node (bridge-spec.md §5). At M1 it is a single-party signer behind +# an HTTP surface; the §5.4 security boundary ("the Outbox committed this exact +# message at source finality") is enforced via the `SourceVerifier` trait before +# any signature is produced. Seal share-load, DKG, and Nautilus attestation +# (the admin endpoints) are M3/M4 and currently stubbed. + +[dependencies] +bridge-types = { workspace = true } +bridge-signer = { workspace = true } +runtime-config = { workspace = true } +observability = { workspace = true, features = ["axum"] } + +config = { version = "0.14", features = ["toml"] } +clap = { workspace = true } + +tokio = { workspace = true } +axum = { workspace = true } +tower-http = { workspace = true } +async-trait = "0.1" +reqwest = { workspace = true } + +serde = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } + +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/rust-backend/services/bridge-signer-service/README.md b/rust-backend/services/bridge-signer-service/README.md new file mode 100644 index 00000000..afce423c --- /dev/null +++ b/rust-backend/services/bridge-signer-service/README.md @@ -0,0 +1,48 @@ +# bridge-signer-service + +The Layer 1 **signer node** ([bridge-spec.md §5](../../../bridge-spec.md)). Wraps +[`bridge-signer`](../../crates/bridge-signer) behind the §5.3 HTTP surface and +enforces the §5.4 security boundary before signing. + +At **M1** it is a single-party signer ("1-of-1"): keys come from config seeds. +The aggregated signature is on-chain-indistinguishable from a later k-of-n one, +so nothing changes on-chain when threshold signing (M3) turns on. + +``` +cargo run -p bridge-signer-service -- --config config.toml +``` + +## Endpoints + +**Public (port 3000):** +| Route | Purpose | +|-------|---------| +| `POST /sign_requests` | `{message}` → verify §5.4 boundary, then sign (Ed25519 for Sui, ECDSA for EVM). **Idempotent per message hash**; returns `202 {message_hash, status, envelope?}`. At M1 the 202 already carries `status:"signed"`; the async surface is for M3 MPC. | +| `GET /sign_requests/:hash` | Poll a session → `{message_hash, status: pending\|signed, envelope?}`, or `404` if unknown. | +| `GET /group_keys` | The Ed25519 pubkey + ECDSA address + ids to register on-chain via `registerGroupKey`. | +| `GET /get_attestation` | **M1 stub** — real Nautilus attestation is M3/M4. | +| `GET /health`, `GET /metrics` | liveness + Prometheus. | + +DoS guardrails (§5.3): the §5.4 verify runs **before** a session is admitted (an +uncommitted message is `422` at the door, never queued); duplicate in-flight +requests coalesce by hash; the session map is bounded + TTL-evicted; and +`POST /sign_requests` is per-IP rate limited. + +**Admin (port 3001, localhost-only in prod):** Seal key-load, share +provisioning, and DKG (`/admin/*`) — all return `501` until M3. + +## The security boundary (§5.4) + +The signer only signs a message the source Outbox committed at finality. That +check is the [`SourceVerifier`](src/verifier.rs) trait: +- `trust_all` — **DEV ONLY** (rejected unless `environment = "dev"`), skips the check. +- `rpc` — verify against ≥2 independent source-chain RPC providers (the + [`RpcVerifier`](src/verifier.rs), spec §5.4): the registered Outbox must have + committed the exact message at the configured confirmation depth. + +## Tests + +`tests/sign.rs` drives the real router: a `POST /sign_requests` returns the exact +signature the on-chain `known_digest_vector` tests expect (end-to-end proof the +service interoperates with the Sui Inbox), a duplicate POST coalesces onto one +session, and an uncommitted message is `422` at the door with no cached session. diff --git a/rust-backend/services/bridge-signer-service/config.example.toml b/rust-backend/services/bridge-signer-service/config.example.toml new file mode 100644 index 00000000..526a402c --- /dev/null +++ b/rust-backend/services/bridge-signer-service/config.example.toml @@ -0,0 +1,58 @@ +# bridge-signer-service (M1, single-party). Copy and fill in. + +public_bind_addr = "0.0.0.0:3000" +admin_bind_addr = "127.0.0.1:3001" + +# Async signing surface (§5.3) — all optional, defaults shown: +# session_ttl_secs = 300 # evict completed sessions after this +# max_sessions = 10000 # bound the in-memory session map (DoS) +# sign_rate_window_secs = 60 # per-IP POST /sign_requests budget window +# sign_rate_max = 120 # ...max requests per window + +# M1-ONLY single-party key seeds (32-byte hex). Prefer ${ENV} expansion so the +# raw seeds never sit in the file. At M3 these are Seal-provisioned in-enclave +# and this section disappears. +ed25519_seed_hex = "${BRIDGE_ED25519_SEED}" +secp256k1_seed_hex = "${BRIDGE_SECP256K1_SEED}" + +# Registered group-key ids the envelope references (must match what was passed +# to registerGroupKey on each Inbox). +# Per-chain registries are independent namespaces; the live deployment uses id 1 +# on both the Sui Inbox (Ed25519) and the EVM Inbox (ECDSA). +ed25519_group_pubkey_id = 1 # Sui group key +ecdsa_group_pubkey_id = 1 # EVM group key + +# 32-byte hex per-deployment salt. The digest domain separator is +# keccak256("XCHAIN_MSG_V1" || salt) (spec §2.2). MUST be byte-identical to the +# value baked into the deployed Sui + EVM contracts and the relayer, or every +# signature will fail to verify on-chain. +deployment_salt_hex = "${BRIDGE_DEPLOYMENT_SALT}" + +# Deployment environment. `trust_all` verification is ONLY allowed when this is +# "dev"; anywhere else the node refuses to start with it. +environment = "dev" + +# Source-commitment verification (§5.4): +# "trust_all" — DEV ONLY, skips the check (signs anything). +# "rpc" — verify the registered Outbox committed the message at finality, +# across every configured provider below. +source_verifier = "trust_all" + +# For source_verifier = "rpc": one entry per chain that can be a message SOURCE. +# §5.4 wants >=2 independent RPC providers per chain (set allow_single_provider +# to override for dev or deterministic-finality Sui). Values below match the +# 2026-07-01 redeploy (see sui-bridge-contracts/DEPLOYMENTS.md). +# +# [[source_chains]] +# internal_chain_id = 268436454 # HyperEVM +# family = "evm" +# rpc_urls = ["https://rpcs.chain.link/hyperevm/testnet", "https://"] +# outbox_addr = "0x1797FAa1eAF0cc1fC7C092Db0035A3c46A357ff6" +# confirmations = 12 +# +# [[source_chains]] +# internal_chain_id = 134217728 # Sui +# family = "sui" +# rpc_urls = ["https://fullnode.testnet.sui.io"] +# package_id = "0x6435311f4d8891f7392cadcc3cc503e71757ba3d24f043f5c04733da7ae6b000" +# allow_single_provider = true # Sui has deterministic checkpoint finality diff --git a/rust-backend/services/bridge-signer-service/examples/verify_evm_smoke.rs b/rust-backend/services/bridge-signer-service/examples/verify_evm_smoke.rs new file mode 100644 index 00000000..05235119 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/examples/verify_evm_smoke.rs @@ -0,0 +1,29 @@ +//! Exercise the real `EvmProbe` against a running EVM node (anvil). +//! +//! Args: +//! Prints the `Commitment` verdict (NotFound | Pending | Final) so a harness can +//! assert the full eth_getLogs + eth_blockNumber + finality path end to end. + +use bridge_signer_service::probe::EvmProbe; +use bridge_signer_service::verifier::CommitmentProbe; + +fn parse(s: &str) -> [u8; N] { + hex::decode(s.trim_start_matches("0x")).unwrap().try_into().unwrap() +} + +#[tokio::main] +async fn main() { + let a: Vec = std::env::args().collect(); + let (rpc, outbox, digest, conf) = + (a[1].clone(), parse::<20>(&a[2]), parse::<32>(&a[3]), a[4].parse::().unwrap()); + + // Large lookback so the anvil scan starts at genesis (no public-RPC range cap). + let probe = EvmProbe::new(reqwest::Client::new(), rpc, outbox, conf, u64::MAX); + match probe.check(&digest).await { + Ok(c) => println!("{c:?}"), + Err(e) => { + eprintln!("ERROR {e:#}"); + std::process::exit(1); + } + } +} diff --git a/rust-backend/services/bridge-signer-service/src/config.rs b/rust-backend/services/bridge-signer-service/src/config.rs new file mode 100644 index 00000000..4f2be049 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/config.rs @@ -0,0 +1,150 @@ +use std::net::SocketAddr; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use bridge_types::message::derive_domain_sep; +use bridge_types::Bytes32; +use runtime_config::config_load; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + /// Public API bind (spec §5.3 port 3000): `/sign_message`, `/health`, + /// `/get_attestation`, `/group_keys`. + pub public_bind_addr: SocketAddr, + /// Admin API bind (spec §5.3 port 3001) — localhost only in production. + pub admin_bind_addr: SocketAddr, + + /// M1-ONLY: 32-byte hex seed for the single-party Ed25519 key (Sui group + /// key). At M3 the share is Seal-provisioned in-enclave instead and never + /// touches config. Supports `${ENV}` expansion. + pub ed25519_seed_hex: String, + /// M1-ONLY: 32-byte hex seed for the single-party secp256k1 key (EVM group + /// key). Must be a valid non-zero scalar below the curve order. + pub secp256k1_seed_hex: String, + + /// Registered group-key ids the envelope references, selected by the + /// destination chain family. + pub ed25519_group_pubkey_id: u32, + pub ecdsa_group_pubkey_id: u32, + + /// 32-byte hex per-deployment salt; the digest domain separator is + /// `keccak256("XCHAIN_MSG_V1" || salt)` (spec §2.2). MUST match the deployed + /// contracts and the relayer. + pub deployment_salt_hex: String, + + /// Deployment environment. `trust_all` verification is only permitted when + /// this is `"dev"`; anywhere else the node refuses to start with it (§5.4). + #[serde(default = "default_environment")] + pub environment: String, + + /// Source-commitment verification mode (spec §5.4): + /// - `trust_all`: DEV ONLY — skips the source check. + /// - `rpc`: verify the registered Outbox committed the message at + /// finality across every configured provider. + #[serde(default = "default_verifier_mode")] + pub source_verifier: String, + + /// Per-source-chain RPC config for `source_verifier = "rpc"` (the enclave's + /// own trusted chain view, spec §5.4). One entry per source chain the node + /// will sign *for* (i.e. every chain that can be a message's source). + #[serde(default)] + pub source_chains: Vec, + + /// TTL (seconds) after which a completed signing session is evicted (§5.3). + #[serde(default = "default_session_ttl_secs")] + pub session_ttl_secs: u64, + /// Max concurrent signing sessions held in memory (DoS bound). + #[serde(default = "default_max_sessions")] + pub max_sessions: usize, + /// Per-IP `POST /sign_requests` budget: `sign_rate_max` per `sign_rate_window_secs`. + #[serde(default = "default_sign_rate_window_secs")] + pub sign_rate_window_secs: u64, + #[serde(default = "default_sign_rate_max")] + pub sign_rate_max: u32, +} + +fn default_lookback_blocks() -> u64 { + // Conservative: some public RPCs cap eth_getLogs at 1000 blocks/query. A + // promptly-relayed message is only seconds old, so this is ample; raise it + // for RPCs that allow wider ranges (or a delayed relay). + 1_000 +} + +fn default_session_ttl_secs() -> u64 { + 300 +} +fn default_max_sessions() -> usize { + 10_000 +} +fn default_sign_rate_window_secs() -> u64 { + 60 +} +fn default_sign_rate_max() -> u32 { + 120 +} + +/// A source chain's verification config: which registered Outbox to look for a +/// commitment on, over which independent RPC providers, at what finality. +#[derive(Debug, Clone, Deserialize)] +pub struct SourceChainConfig { + /// Internal registry id (matches a message's `src_chain_id`). + pub internal_chain_id: u32, + /// `"evm"` or `"sui"` — selects the probe + finality semantics. + pub family: String, + /// Independent RPC endpoints. §5.4 wants ≥2 unless `allow_single_provider`. + pub rpc_urls: Vec, + /// EVM: the registered Outbox contract address (0x, 20 bytes). + #[serde(default)] + pub outbox_addr: Option, + /// Sui: the deployed bridge package id (holds `events::MessageCommitted`). + #[serde(default)] + pub package_id: Option, + /// EVM confirmation depth before a commitment counts as final (§4). Sui uses + /// deterministic checkpoint finality, so this is ignored there. + #[serde(default)] + pub confirmations: u64, + /// EVM: how many blocks back from head to scan for the commitment. Public + /// RPCs reject unbounded `eth_getLogs` ranges, and a relayed message is + /// recent, so we scan a bounded window (default ~10k blocks). + #[serde(default = "default_lookback_blocks")] + pub lookback_blocks: u64, + /// Allow a single RPC provider (dev / Sui-deterministic). Off by default so + /// production must configure ≥2 independent providers. + #[serde(default)] + pub allow_single_provider: bool, +} + +fn default_environment() -> String { + "prod".to_string() +} + +fn default_verifier_mode() -> String { + "trust_all".to_string() +} + +impl Config { + pub fn load(path: impl AsRef) -> Result { + config_load::load_toml(path) + } + + pub fn ed25519_seed(&self) -> Result<[u8; 32]> { + parse_seed(&self.ed25519_seed_hex).context("ed25519_seed_hex") + } + + pub fn secp256k1_seed(&self) -> Result<[u8; 32]> { + parse_seed(&self.secp256k1_seed_hex).context("secp256k1_seed_hex") + } + + /// Derive the digest domain separator from the configured deployment salt. + pub fn domain_sep(&self) -> Result { + Ok(derive_domain_sep(&parse_seed(&self.deployment_salt_hex).context("deployment_salt_hex")?)) + } +} + +fn parse_seed(s: &str) -> Result<[u8; 32]> { + let bytes = hex::decode(s.trim_start_matches("0x")).context("decoding hex seed")?; + bytes + .try_into() + .map_err(|v: Vec| anyhow!("seed must be 32 bytes, got {}", v.len())) +} diff --git a/rust-backend/services/bridge-signer-service/src/handlers.rs b/rust-backend/services/bridge-signer-service/src/handlers.rs new file mode 100644 index 00000000..d3bf7785 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/handlers.rs @@ -0,0 +1,210 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use bridge_types::{chain_id, CrossChainMessage, Scheme, SignatureEnvelope}; + +use crate::sessions::{Admit, SignStatus}; +use crate::state::AppState; +use crate::verifier::VerifyError; + +#[derive(Debug, Deserialize)] +pub struct SignRequest { + pub message: CrossChainMessage, +} + +/// Async session status returned by POST (202) and GET. +#[derive(Debug, Serialize)] +pub struct SignRequestResponse { + /// `0x`-prefixed keccak256 digest the signature is (or will be) over. + pub message_hash: String, + /// `"pending"` | `"signed"`. + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub envelope: Option, +} + +fn to_response(message_hash: String, status: &SignStatus) -> SignRequestResponse { + match status { + SignStatus::Pending => SignRequestResponse { message_hash, status: "pending", envelope: None }, + SignStatus::Signed(env) => SignRequestResponse { + message_hash, + status: "signed", + envelope: Some((**env).clone()), + }, + } +} + +/// POST /sign_requests (spec §5.3). Idempotent per message hash: a duplicate +/// coalesces onto the existing session. A newly-admitted request is verified +/// against the §5.4 source boundary *before* any signing work — an uncommitted +/// message is rejected at the door (422) and never queued. At M1 signing runs +/// inline, so the 202 usually already carries `status: "signed"`. +pub async fn create_sign_request( + State(state): State>, + peer: Option>, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + // Per-IP rate limit (skipped when the peer addr is absent, e.g. in tests). + if let Some(ConnectInfo(addr)) = peer { + if !state.rate_limiter.check(addr.ip()) { + return Err(ApiError::RateLimited); + } + } + + let message = req.message; + let family = chain_id::family(message.dst_chain_id); + let scheme = Scheme::for_family(family).ok_or(ApiError::UnsupportedFamily(family))?; + let hash = format!("0x{}", hex::encode(message.digest(&state.domain_sep))); + + // Claim the session (or coalesce). In-flight dedup: a concurrent duplicate + // sees the Pending marker and returns without re-verifying/re-signing. + match state.sessions.admit(&hash) { + Admit::Existing(status) => return Ok((StatusCode::ACCEPTED, Json(to_response(hash, &status)))), + Admit::Full => return Err(ApiError::Busy), + Admit::New => {} + } + + // §5.4 boundary. On failure, abandon the session (don't cache a rejection — + // the message may reach finality later) and 422 at the door. + if let Err(e) = state.verifier.verify_committed(&message).await { + state.sessions.abandon(&hash); + return Err(ApiError::from(e)); + } + + let group_pubkey_id = match scheme { + Scheme::Ed25519 => state.ed25519_group_pubkey_id, + Scheme::EcdsaSecp256k1 => state.ecdsa_group_pubkey_id, + }; + match state.signer.sign(&message, &state.domain_sep, group_pubkey_id) { + Ok(envelope) => { + state.sessions.finalize(&hash, envelope.clone()); + Ok((StatusCode::ACCEPTED, Json(to_response(hash, &SignStatus::Signed(Box::new(envelope)))))) + } + Err(e) => { + state.sessions.abandon(&hash); + Err(ApiError::Sign(e.to_string())) + } + } +} + +/// GET /sign_requests/{message_hash} — poll a session by its `0x`-hash. +pub async fn get_sign_request( + State(state): State>, + Path(message_hash): Path, +) -> Result, ApiError> { + let hash = normalize_hash(&message_hash); + match state.sessions.get(&hash) { + Some(status) => Ok(Json(to_response(hash, &status))), + None => Err(ApiError::NotFound), + } +} + +fn normalize_hash(h: &str) -> String { + let h = h.trim().to_lowercase(); + if h.starts_with("0x") { + h + } else { + format!("0x{h}") + } +} + +#[derive(Debug, Serialize)] +pub struct GroupKeysResponse { + /// 32-byte Ed25519 group pubkey to register as the Sui group key. + pub ed25519_pubkey: String, + /// 20-byte ECDSA group address to register as the EVM group key. + pub ecdsa_address: String, + pub ed25519_group_pubkey_id: u32, + pub ecdsa_group_pubkey_id: u32, +} + +/// GET /group_keys — the keys/ids operators register on-chain via +/// `registerGroupKey`. Not in the spec's endpoint list, but the natural wiring +/// surface for the 1-of-1 launch. +pub async fn group_keys(State(state): State>) -> Json { + Json(GroupKeysResponse { + ed25519_pubkey: format!("0x{}", hex::encode(state.signer.ed25519_group_pubkey())), + ecdsa_address: format!("0x{}", hex::encode(state.signer.ecdsa_group_address())), + ed25519_group_pubkey_id: state.ed25519_group_pubkey_id, + ecdsa_group_pubkey_id: state.ecdsa_group_pubkey_id, + }) +} + +/// GET /get_attestation (spec §5.3). M1 stub: real Nautilus remote attestation +/// (PCRs, enclave-bound ephemeral key) arrives at M3/M4. Returns the group keys +/// so the surface is still useful for wiring. +pub async fn get_attestation(State(state): State>) -> Json { + Json(json!({ + "attested": false, + "note": "M1 stub — no Nautilus attestation yet (M3/M4)", + "ed25519_group_pubkey": format!("0x{}", hex::encode(state.signer.ed25519_group_pubkey())), + "ecdsa_group_address": format!("0x{}", hex::encode(state.signer.ecdsa_group_address())), + })) +} + +pub async fn health() -> &'static str { + "ok" +} + +/// Admin endpoints (spec §5.3): Seal key-load + share provisioning + DKG. All +/// stubbed at M1 (single-party keys come from config); implemented at M3. +pub async fn admin_not_implemented() -> Response { + ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ "error": "not implemented until M3 (Seal key-load / share provisioning / DKG)" })), + ) + .into_response() +} + +/// Maps signing/verification failures to HTTP status codes. +#[derive(Debug)] +pub enum ApiError { + UnsupportedFamily(u8), + Verify(VerifyError), + Sign(String), + RateLimited, + Busy, + NotFound, +} + +impl From for ApiError { + fn from(e: VerifyError) -> Self { + ApiError::Verify(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match self { + ApiError::UnsupportedFamily(f) => ( + StatusCode::BAD_REQUEST, + format!("destination family {f} has no supported signature scheme"), + ), + ApiError::Verify(e @ VerifyError::NotCommitted { .. }) => { + (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()) + } + ApiError::Verify(e @ VerifyError::UnknownRoute(_)) => { + (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()) + } + ApiError::Verify(e @ VerifyError::Unavailable(_)) => { + (StatusCode::SERVICE_UNAVAILABLE, e.to_string()) + } + ApiError::Sign(m) => (StatusCode::INTERNAL_SERVER_ERROR, format!("signing failed: {m}")), + ApiError::RateLimited => { + (StatusCode::TOO_MANY_REQUESTS, "per-IP sign-request rate limit exceeded".into()) + } + ApiError::Busy => { + (StatusCode::SERVICE_UNAVAILABLE, "signer session capacity reached".into()) + } + ApiError::NotFound => (StatusCode::NOT_FOUND, "no signing session for that hash".into()), + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/rust-backend/services/bridge-signer-service/src/lib.rs b/rust-backend/services/bridge-signer-service/src/lib.rs new file mode 100644 index 00000000..d0fdf58c --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/lib.rs @@ -0,0 +1,26 @@ +//! The signer node service (bridge-spec.md §5). Wraps `bridge-signer` behind +//! the §5.3 HTTP surface and enforces the §5.4 source-commitment boundary +//! before signing. + +use std::path::PathBuf; + +use clap::Parser; + +pub mod config; +pub mod handlers; +pub mod probe; +pub mod ratelimit; +pub mod router; +pub mod sessions; +pub mod state; +pub mod verifier; + +pub use config::Config; +pub use state::AppState; + +#[derive(Debug, Parser)] +#[command(name = "bridge-signer-service", about = "Layer 1 signer node (M1, single-party)")] +pub struct Cli { + #[arg(long, default_value = "config.toml")] + pub config: PathBuf, +} diff --git a/rust-backend/services/bridge-signer-service/src/main.rs b/rust-backend/services/bridge-signer-service/src/main.rs new file mode 100644 index 00000000..1e190e3a --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/main.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use tracing::{error, info}; + +use bridge_signer::ThresholdSigner; +use bridge_signer_service::{router, verifier, AppState, Cli, Config}; + +#[tokio::main] +async fn main() -> Result<()> { + let _obs = observability::init("bridge-signer-service"); + + let cli = Cli::parse(); + let cfg = Config::load(&cli.config) + .with_context(|| format!("loading config from {}", cli.config.display()))?; + + let signer = ThresholdSigner::from_seeds(cfg.ed25519_seed()?, cfg.secp256k1_seed()?) + .context("building signer from seeds")?; + info!( + ed25519_group_pubkey = %format!("0x{}", hex::encode(signer.ed25519_group_pubkey())), + ecdsa_group_address = %format!("0x{}", hex::encode(signer.ecdsa_group_address())), + "signer keys loaded (M1 single-party)" + ); + + let domain_sep = cfg.domain_sep().context("deriving domain separator")?; + let verifier = + verifier::build(&cfg.source_verifier, &cfg.environment, domain_sep, &cfg.source_chains) + .context("building source verifier")?; + + let state = Arc::new(AppState { + signer, + verifier, + domain_sep, + ed25519_group_pubkey_id: cfg.ed25519_group_pubkey_id, + ecdsa_group_pubkey_id: cfg.ecdsa_group_pubkey_id, + sessions: bridge_signer_service::sessions::SessionStore::new(cfg.session_ttl_secs, cfg.max_sessions), + rate_limiter: bridge_signer_service::ratelimit::RateLimiter::new( + cfg.sign_rate_window_secs, + cfg.sign_rate_max, + ), + }); + + let public_addr = cfg.public_bind_addr; + let admin_addr = cfg.admin_bind_addr; + let public = tokio::spawn(router::serve_public(public_addr, Arc::clone(&state))); + let admin = tokio::spawn(router::serve_admin(admin_addr)); + + tokio::select! { + res = public => match res { + Ok(Ok(())) => info!("public API finished"), + Ok(Err(e)) => error!(error = %e, "public API exited"), + Err(e) => error!(error = %e, "public API task panicked"), + }, + res = admin => match res { + Ok(Ok(())) => info!("admin API finished"), + Ok(Err(e)) => error!(error = %e, "admin API exited"), + Err(e) => error!(error = %e, "admin API task panicked"), + }, + } + Ok(()) +} diff --git a/rust-backend/services/bridge-signer-service/src/probe.rs b/rust-backend/services/bridge-signer-service/src/probe.rs new file mode 100644 index 00000000..c7c4a033 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/probe.rs @@ -0,0 +1,301 @@ +//! Concrete [`CommitmentProbe`]s: one per source-chain family, talking raw +//! JSON-RPC to a single RPC provider. The network-touching methods are thin; the +//! response interpretation lives in pure functions (`evm_find_log_block`, +//! `evm_finality`, `sui_events_contain_digest`) that are unit-tested directly. +//! +//! - **EVM** (`eth_getLogs` + `eth_blockNumber`): the Outbox's `MessageCommitted` +//! event has the message hash as an indexed topic, so a `{address, [topic0, +//! digest]}` filter is an exact lookup. Finality = confirmation depth. +//! - **Sui** (`suix_queryEvents`): query the package's `MessageCommitted` events +//! and match `message_hash`. Sui events are only returned once finalized, so a +//! match is `Final` (no pending state). + +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use bridge_types::message::keccak256; +use bridge_types::Bytes32; +use serde_json::{json, Value}; + +use crate::config::SourceChainConfig; +use crate::verifier::{Commitment, CommitmentProbe}; + +/// Solidity: `MessageCommitted(bytes32 indexed messageHash, uint32, uint32, uint64, bytes32, bytes32, bytes)`. +const EVM_EVENT_SIG: &[u8] = b"MessageCommitted(bytes32,uint32,uint32,uint64,bytes32,bytes32,bytes)"; +/// How many Sui event pages to scan before giving up (newest-first). +const SUI_MAX_PAGES: usize = 20; + +/// Build the probes for one configured source chain (one probe per RPC url). +pub fn build_probes(sc: &SourceChainConfig) -> Result>> { + let http = reqwest::Client::builder() + .build() + .context("building reqwest client")?; + match sc.family.as_str() { + "evm" => { + let outbox_hex = sc + .outbox_addr + .as_ref() + .ok_or_else(|| anyhow!("evm source {} needs outbox_addr", sc.internal_chain_id))?; + let outbox = parse_evm_address(outbox_hex)?; + Ok(sc + .rpc_urls + .iter() + .map(|url| { + Box::new(EvmProbe::new( + http.clone(), + url.clone(), + outbox, + sc.confirmations, + sc.lookback_blocks, + )) as Box + }) + .collect()) + } + "sui" => { + let package = sc + .package_id + .as_ref() + .ok_or_else(|| anyhow!("sui source {} needs package_id", sc.internal_chain_id))?; + Ok(sc + .rpc_urls + .iter() + .map(|url| { + Box::new(SuiProbe::new(http.clone(), url.clone(), package.clone())) + as Box + }) + .collect()) + } + other => bail!("source chain {} has unknown family {other:?}", sc.internal_chain_id), + } +} + +fn parse_evm_address(s: &str) -> Result<[u8; 20]> { + let bytes = hex::decode(s.trim_start_matches("0x")).context("decoding outbox_addr hex")?; + bytes.try_into().map_err(|v: Vec| anyhow!("outbox_addr must be 20 bytes, got {}", v.len())) +} + +fn host_label(url: &str) -> String { + url.split("://").nth(1).unwrap_or(url).split('/').next().unwrap_or(url).to_string() +} + +async fn rpc(http: &reqwest::Client, url: &str, method: &str, params: Value) -> Result { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let resp: Value = http + .post(url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {method}"))? + .error_for_status()? + .json() + .await + .context("decoding JSON-RPC response")?; + if let Some(err) = resp.get("error") { + bail!("JSON-RPC error from {method}: {err}"); + } + resp.get("result").cloned().ok_or_else(|| anyhow!("{method} response missing result")) +} + +// --- EVM --- + +pub struct EvmProbe { + http: reqwest::Client, + url: String, + label: String, + outbox: [u8; 20], + confirmations: u64, + /// How many blocks back from head to scan. Public RPCs cap `eth_getLogs` + /// block ranges (an unbounded `fromBlock: 0x0` is rejected as "too large"), + /// and a message being relayed is recent, so we only scan a bounded window. + /// A commitment older than this reads as NotFound → fail-closed refusal. + lookback_blocks: u64, + topic0: Bytes32, +} + +impl EvmProbe { + pub fn new( + http: reqwest::Client, + url: String, + outbox: [u8; 20], + confirmations: u64, + lookback_blocks: u64, + ) -> Self { + let label = host_label(&url); + Self { http, label, url, outbox, confirmations, lookback_blocks, topic0: keccak256(EVM_EVENT_SIG) } + } +} + +#[async_trait] +impl CommitmentProbe for EvmProbe { + fn label(&self) -> &str { + &self.label + } + + async fn check(&self, digest: &Bytes32) -> Result { + let latest_hex = rpc(&self.http, &self.url, "eth_blockNumber", json!([])).await?; + let latest = parse_hex_u64(&latest_hex).context("parsing eth_blockNumber")?; + let from = latest.saturating_sub(self.lookback_blocks); + let filter = json!({ + "address": format!("0x{}", hex::encode(self.outbox)), + "topics": [format!("0x{}", hex::encode(self.topic0)), format!("0x{}", hex::encode(digest))], + "fromBlock": format!("0x{from:x}"), + "toBlock": "latest", + }); + let logs = rpc(&self.http, &self.url, "eth_getLogs", json!([filter])).await?; + let Some(block) = evm_find_log_block(&logs) else { + return Ok(Commitment::NotFound); + }; + Ok(evm_finality(block, latest, self.confirmations)) + } +} + +/// Block number of the first log in an `eth_getLogs` result, or `None` if empty. +pub fn evm_find_log_block(result: &Value) -> Option { + let first = result.as_array()?.first()?; + parse_hex_u64(first.get("blockNumber")?).ok() +} + +/// Confirmation-depth finality: a log at `block` is `Final` once at least +/// `confirmations` blocks sit on top of it (`latest - block >= confirmations`). +pub fn evm_finality(block: u64, latest: u64, confirmations: u64) -> Commitment { + if latest.saturating_sub(block) >= confirmations { + Commitment::Final + } else { + Commitment::Pending + } +} + +fn parse_hex_u64(v: &Value) -> Result { + let s = v.as_str().ok_or_else(|| anyhow!("expected hex string, got {v}"))?; + u64::from_str_radix(s.trim_start_matches("0x"), 16).context("parsing hex u64") +} + +// --- Sui --- + +pub struct SuiProbe { + http: reqwest::Client, + url: String, + label: String, + committed_type: String, +} + +impl SuiProbe { + pub fn new(http: reqwest::Client, url: String, package: String) -> Self { + let label = host_label(&url); + let committed_type = format!("{package}::events::MessageCommitted"); + Self { http, label, url, committed_type } + } +} + +#[async_trait] +impl CommitmentProbe for SuiProbe { + fn label(&self) -> &str { + &self.label + } + + async fn check(&self, digest: &Bytes32) -> Result { + let filter = json!({ "MoveEventType": self.committed_type }); + let mut cursor = Value::Null; + for _ in 0..SUI_MAX_PAGES { + // newest-first (descending) so recent commitments are found fast. + let page = rpc( + &self.http, + &self.url, + "suix_queryEvents", + json!([filter, cursor, 50, true]), + ) + .await?; + if sui_events_contain_digest(&page, digest) { + // Sui only returns finalized-checkpoint events → committed == final. + return Ok(Commitment::Final); + } + if !page.get("hasNextPage").and_then(Value::as_bool).unwrap_or(false) { + break; + } + cursor = page.get("nextCursor").cloned().unwrap_or(Value::Null); + } + Ok(Commitment::NotFound) + } +} + +/// Does any event in a `suix_queryEvents` page carry `message_hash == digest`? +pub fn sui_events_contain_digest(page: &Value, digest: &Bytes32) -> bool { + let Some(data) = page.get("data").and_then(Value::as_array) else { + return false; + }; + data.iter().any(|ev| { + ev.get("parsedJson") + .and_then(|p| p.get("message_hash")) + .and_then(json_bytes) + .is_some_and(|b| b.as_slice() == digest.as_slice()) + }) +} + +/// Decode a Move `vector` JSON rendering: array-of-numbers or `0x`-hex. +fn json_bytes(v: &Value) -> Option> { + match v { + Value::Array(items) => items.iter().map(|x| x.as_u64().and_then(|n| u8::try_from(n).ok())).collect(), + Value::String(s) => hex::decode(s.trim_start_matches("0x")).ok(), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evm_finality_thresholds() { + assert_eq!(evm_finality(100, 111, 12), Commitment::Pending); // 11 on top + assert_eq!(evm_finality(100, 112, 12), Commitment::Final); // 12 on top + assert_eq!(evm_finality(100, 200, 12), Commitment::Final); + assert_eq!(evm_finality(100, 100, 0), Commitment::Final); // 0-conf: any inclusion + } + + #[test] + fn evm_find_block_parses_first_log() { + let logs = json!([{ "blockNumber": "0x2a", "data": "0x" }, { "blockNumber": "0x2b" }]); + assert_eq!(evm_find_log_block(&logs), Some(42)); + } + + #[test] + fn evm_find_block_empty_is_none() { + assert_eq!(evm_find_log_block(&json!([])), None); + } + + #[test] + fn sui_matches_digest_array_form() { + let digest = [0xab; 32]; + let page = json!({ + "data": [ + { "parsedJson": { "message_hash": vec![0x00u8; 32] } }, + { "parsedJson": { "message_hash": vec![0xabu8; 32] } }, + ], + "hasNextPage": false, + }); + assert!(sui_events_contain_digest(&page, &digest)); + } + + #[test] + fn sui_matches_digest_hex_form() { + let digest = [0xcd; 32]; + let page = json!({ + "data": [ { "parsedJson": { "message_hash": format!("0x{}", "cd".repeat(32)) } } ], + }); + assert!(sui_events_contain_digest(&page, &digest)); + } + + #[test] + fn sui_no_match_when_absent() { + let page = json!({ "data": [ { "parsedJson": { "message_hash": vec![0x11u8; 32] } } ] }); + assert!(!sui_events_contain_digest(&page, &[0x22; 32])); + } + + #[test] + fn evm_event_topic0_is_stable() { + // Guards against accidental event-signature drift. + assert_eq!( + hex::encode(keccak256(EVM_EVENT_SIG)), + hex::encode(keccak256(b"MessageCommitted(bytes32,uint32,uint32,uint64,bytes32,bytes32,bytes)")) + ); + } +} diff --git a/rust-backend/services/bridge-signer-service/src/ratelimit.rs b/rust-backend/services/bridge-signer-service/src/ratelimit.rs new file mode 100644 index 00000000..7caf134a --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/ratelimit.rs @@ -0,0 +1,62 @@ +//! Minimal fixed-window per-IP rate limiter for the public `POST /sign_requests` +//! surface (bridge-spec.md §5.3 DoS guardrails). The verify-before-admit check +//! is the primary guard; this caps request churn from any single source. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +struct Window { + start: Instant, + count: u32, +} + +pub struct RateLimiter { + inner: Mutex>, + window: Duration, + max: u32, +} + +impl RateLimiter { + pub fn new(window_secs: u64, max: u32) -> Self { + Self { inner: Mutex::new(HashMap::new()), window: Duration::from_secs(window_secs), max } + } + + /// Record a request from `ip`; returns false if it exceeds `max` per window. + pub fn check(&self, ip: IpAddr) -> bool { + let mut map = self.inner.lock().unwrap(); + let now = Instant::now(); + let w = map.entry(ip).or_insert(Window { start: now, count: 0 }); + if now.duration_since(w.start) >= self.window { + w.start = now; + w.count = 0; + } + w.count += 1; + w.count <= self.max + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_up_to_max_then_blocks() { + let rl = RateLimiter::new(60, 2); + let ip: IpAddr = "1.2.3.4".parse().unwrap(); + assert!(rl.check(ip)); // 1 + assert!(rl.check(ip)); // 2 + assert!(!rl.check(ip)); // 3 → blocked + // a different IP is independent + assert!(rl.check("5.6.7.8".parse().unwrap())); + } + + #[test] + fn window_resets() { + let rl = RateLimiter::new(0, 1); // window 0 → every call is a fresh window + let ip: IpAddr = "1.2.3.4".parse().unwrap(); + assert!(rl.check(ip)); + assert!(rl.check(ip)); // window elapsed (0s) → reset, allowed again + } +} diff --git a/rust-backend/services/bridge-signer-service/src/router.rs b/rust-backend/services/bridge-signer-service/src/router.rs new file mode 100644 index 00000000..27b470be --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/router.rs @@ -0,0 +1,55 @@ +//! Two axum routers on two ports (spec §5.3): +//! - [`serve_public`] (3000): `/health`, `/get_attestation`, `/group_keys`, +//! `POST /sign_requests`, `GET /sign_requests/{hash}`. +//! - [`serve_admin`] (3001, localhost-only in prod): Seal key-load + share +//! provisioning + DKG — all stubbed at M1. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Result; +use axum::routing::{get, post}; +use axum::Router; +use tracing::info; + +use crate::handlers; +use crate::state::AppState; + +pub fn public_router(state: Arc) -> Router { + Router::new() + .route("/health", get(handlers::health)) + .route("/get_attestation", get(handlers::get_attestation)) + .route("/group_keys", get(handlers::group_keys)) + .route("/sign_requests", post(handlers::create_sign_request)) + .route("/sign_requests/:message_hash", get(handlers::get_sign_request)) + .with_state(state) + .merge(observability::middleware::metrics_route()) + .layer(axum::middleware::from_fn(observability::middleware::http_obs)) +} + +pub fn admin_router() -> Router { + Router::new() + .route("/health", get(handlers::health)) + .route("/admin/init_seal_key_load", post(handlers::admin_not_implemented)) + .route("/admin/complete_seal_key_load", post(handlers::admin_not_implemented)) + .route("/admin/provision_ecdsa_share", post(handlers::admin_not_implemented)) + .route("/admin/provision_ed25519_share", post(handlers::admin_not_implemented)) + .route("/admin/dkg/start", post(handlers::admin_not_implemented)) + .layer(axum::middleware::from_fn(observability::middleware::http_obs)) +} + +pub async fn serve_public(addr: SocketAddr, state: Arc) -> Result<()> { + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(%addr, "bridge-signer public API listening"); + // ConnectInfo makes the peer address available for the per-IP rate limiter. + axum::serve(listener, public_router(state).into_make_service_with_connect_info::()) + .await?; + Ok(()) +} + +pub async fn serve_admin(addr: SocketAddr) -> Result<()> { + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(%addr, "bridge-signer admin API listening"); + axum::serve(listener, admin_router()).await?; + Ok(()) +} diff --git a/rust-backend/services/bridge-signer-service/src/sessions.rs b/rust-backend/services/bridge-signer-service/src/sessions.rs new file mode 100644 index 00000000..de9acd37 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/sessions.rs @@ -0,0 +1,148 @@ +//! In-memory signing-session store (bridge-spec.md §5.3). Keyed by the 32-byte +//! message hash so a session is created **at most once per digest** — duplicate +//! `POST /sign_requests` for the same message coalesce onto the existing session +//! instead of re-verifying and re-signing. At M1 a session completes inline +//! (verify → sign) before the POST returns; the async surface is what M3's +//! multi-round MPC swaps its internals under without changing the interface. +//! +//! Terminal (`Signed`) sessions are TTL-evicted; the map is bounded to cap the +//! DoS surface. Verify failures are NOT cached — they 422 at the door and the +//! session is abandoned so a later retry (once the source reaches finality) can +//! re-admit. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use bridge_types::SignatureEnvelope; + +/// The state of a signing session, as returned to pollers. +#[derive(Clone, Debug)] +pub enum SignStatus { + /// Admitted; signing in progress (trivially fast at M1). + Pending, + /// Completed — the aggregated signature envelope is ready. + Signed(Box), +} + +struct Entry { + status: SignStatus, + /// When this entry last became its current state (for TTL eviction). + stamped: Instant, +} + +/// Result of trying to claim a session for a message hash. +pub enum Admit { + /// A session already exists; here is its current status (coalesced). + Existing(SignStatus), + /// Freshly claimed — the caller owns it and must `finalize` or `abandon`. + New, + /// The bounded map is full; shed load. + Full, +} + +pub struct SessionStore { + inner: Mutex>, + ttl: Duration, + cap: usize, +} + +impl SessionStore { + pub fn new(ttl_secs: u64, cap: usize) -> Self { + Self { inner: Mutex::new(HashMap::new()), ttl: Duration::from_secs(ttl_secs), cap } + } + + /// Evict terminal sessions older than the TTL. Pending sessions are kept + /// (they're in-flight). Caller must hold the lock. + fn evict(&self, map: &mut HashMap) { + let ttl = self.ttl; + map.retain(|_, e| matches!(e.status, SignStatus::Pending) || e.stamped.elapsed() < ttl); + } + + /// Claim a session for `hash`, or coalesce onto an existing one. + pub fn admit(&self, hash: &str) -> Admit { + let mut map = self.inner.lock().unwrap(); + self.evict(&mut map); + if let Some(e) = map.get(hash) { + return Admit::Existing(e.status.clone()); + } + if map.len() >= self.cap { + return Admit::Full; + } + map.insert(hash.to_string(), Entry { status: SignStatus::Pending, stamped: Instant::now() }); + Admit::New + } + + /// Mark a claimed session complete with its signature. + pub fn finalize(&self, hash: &str, envelope: SignatureEnvelope) { + let mut map = self.inner.lock().unwrap(); + if let Some(e) = map.get_mut(hash) { + e.status = SignStatus::Signed(Box::new(envelope)); + e.stamped = Instant::now(); + } + } + + /// Drop a claimed session (verify failed / signing error) so it can retry. + pub fn abandon(&self, hash: &str) { + self.inner.lock().unwrap().remove(hash); + } + + /// Current status of a session, if any. + pub fn get(&self, hash: &str) -> Option { + let mut map = self.inner.lock().unwrap(); + self.evict(&mut map); + map.get(hash).map(|e| e.status.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_types::envelope::SCHEME_ED25519; + + fn env() -> SignatureEnvelope { + SignatureEnvelope { scheme_tag: SCHEME_ED25519, group_pubkey_id: 1, signature: vec![1, 2, 3] } + } + + #[test] + fn admit_is_idempotent_per_hash() { + let s = SessionStore::new(60, 100); + assert!(matches!(s.admit("0xaa"), Admit::New)); + // second admit coalesces onto the pending session + assert!(matches!(s.admit("0xaa"), Admit::Existing(SignStatus::Pending))); + s.finalize("0xaa", env()); + assert!(matches!(s.admit("0xaa"), Admit::Existing(SignStatus::Signed(_)))); + } + + #[test] + fn abandon_allows_readmit() { + let s = SessionStore::new(60, 100); + assert!(matches!(s.admit("0xbb"), Admit::New)); + s.abandon("0xbb"); + assert!(s.get("0xbb").is_none()); + assert!(matches!(s.admit("0xbb"), Admit::New)); // retryable + } + + #[test] + fn bounded_map_sheds_load() { + let s = SessionStore::new(60, 1); + assert!(matches!(s.admit("0x1"), Admit::New)); + assert!(matches!(s.admit("0x2"), Admit::Full)); // cap reached + } + + #[test] + fn terminal_sessions_evict_after_ttl() { + let s = SessionStore::new(0, 100); // ttl 0 → signed entries expire immediately + s.admit("0xcc"); + s.finalize("0xcc", env()); + // a Signed entry with elapsed() >= 0 ttl is evicted on next access + assert!(s.get("0xcc").is_none()); + } + + #[test] + fn pending_survives_eviction() { + let s = SessionStore::new(0, 100); + s.admit("0xdd"); // Pending, ttl 0 + assert!(matches!(s.get("0xdd"), Some(SignStatus::Pending))); + } +} diff --git a/rust-backend/services/bridge-signer-service/src/state.rs b/rust-backend/services/bridge-signer-service/src/state.rs new file mode 100644 index 00000000..637ec1cc --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/state.rs @@ -0,0 +1,23 @@ +use bridge_signer::ThresholdSigner; +use bridge_types::Bytes32; + +use crate::ratelimit::RateLimiter; +use crate::sessions::SessionStore; +use crate::verifier::SourceVerifier; + +/// Shared signer state. At M1 the keys live in process memory loaded from config; +/// at M3 they become Seal-provisioned shares loaded in-enclave. +pub struct AppState { + pub signer: ThresholdSigner, + pub verifier: Box, + /// Digest domain separator (spec §2.2), derived from the deployment salt. + pub domain_sep: Bytes32, + /// Group-key id the envelope references for Sui-destined (Ed25519) messages. + pub ed25519_group_pubkey_id: u32, + /// Group-key id for EVM-destined (ECDSA) messages. + pub ecdsa_group_pubkey_id: u32, + /// Signing sessions, keyed by message hash (§5.3 async surface). + pub sessions: SessionStore, + /// Per-IP limiter for `POST /sign_requests`. + pub rate_limiter: RateLimiter, +} diff --git a/rust-backend/services/bridge-signer-service/src/verifier.rs b/rust-backend/services/bridge-signer-service/src/verifier.rs new file mode 100644 index 00000000..5d7998b6 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/src/verifier.rs @@ -0,0 +1,295 @@ +//! The §5.4 security boundary. Before signing, the node confirms the message was +//! committed by the *registered* source Outbox AND that the commitment is final +//! — the narrow, auditable check that makes the signer safe (not free-form event +//! scraping). Anything the source can't prove committed-at-finality is refused. +//! +//! Structure: +//! - [`SourceVerifier`] — the boundary the handler calls before signing. +//! - [`CommitmentProbe`] — one source-chain RPC provider's view of "is this +//! digest committed at finality?". Mockable, so the quorum logic is unit-tested +//! without a network; concrete EVM/Sui probes live in [`crate::probe`]. +//! - [`RpcVerifier`] — per source chain, requires **every configured provider** +//! to independently confirm the commitment (spec §5.4: ≥2 independent RPCs). +//! A single provider is allowed only with an explicit opt-in. + +use std::collections::HashMap; + +use anyhow::{bail, Result}; +use async_trait::async_trait; +use bridge_types::{chain_id, Bytes32, CrossChainMessage}; +use thiserror::Error; +use tracing::warn; + +use crate::config::SourceChainConfig; + +#[derive(Debug, Error)] +pub enum VerifyError { + /// No source route is configured for the message's `src_chain_id`. + #[error("no configured source route for src_chain_id={0}")] + UnknownRoute(u32), + /// The Outbox has not committed this exact message (or not yet at finality). + #[error("source Outbox has not committed message (src_chain={src_chain_id}, nonce={nonce}) at finality")] + NotCommitted { src_chain_id: u32, nonce: u64 }, + /// A provider was unreachable, or providers disagreed — fail closed. + #[error("source verification unavailable: {0}")] + Unavailable(String), +} + +/// One source-chain provider's answer for a specific digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Commitment { + /// The registered Outbox committed this digest and it is past finality. + Final, + /// Committed, but not yet at the required confirmation depth. + Pending, + /// No matching commitment found on this provider. + NotFound, +} + +/// A single RPC provider's view of a source chain's Outbox commitments. +#[async_trait] +pub trait CommitmentProbe: Send + Sync { + /// Short label (host) for logs. + fn label(&self) -> &str; + /// Does this provider see `digest` committed by the registered Outbox at + /// finality? Errors are transport/availability failures (fail closed). + async fn check(&self, digest: &Bytes32) -> Result; +} + +#[async_trait] +pub trait SourceVerifier: Send + Sync { + async fn verify_committed(&self, message: &CrossChainMessage) -> Result<(), VerifyError>; +} + +/// DEV ONLY. Skips the source-commitment check entirely — signs any well-formed +/// message. The trust model collapses without §5.4, so `build` refuses to +/// construct this outside `environment = "dev"`. +pub struct TrustAllVerifier; + +#[async_trait] +impl SourceVerifier for TrustAllVerifier { + async fn verify_committed(&self, _message: &CrossChainMessage) -> Result<(), VerifyError> { + Ok(()) + } +} + +/// Per-source-chain set of probes. Every probe must independently confirm the +/// commitment (quorum = all configured providers). +struct SourceRoute { + probes: Vec>, +} + +/// The real §5.4 verifier: recompute the message's domain-separated digest and +/// require every configured RPC provider for its source chain to confirm the +/// registered Outbox committed it at finality. +pub struct RpcVerifier { + domain_sep: Bytes32, + sources: HashMap, +} + +impl RpcVerifier { + /// Assemble from the signer config's `[[source_chains]]`. Validates the + /// provider count (≥2 unless `allow_single_provider`) and family wiring. + pub fn from_config(domain_sep: Bytes32, source_chains: &[SourceChainConfig]) -> Result { + let mut sources = HashMap::new(); + for sc in source_chains { + let probes = crate::probe::build_probes(sc)?; + if probes.len() < 2 && !sc.allow_single_provider { + bail!( + "source chain {} has {} RPC provider(s); §5.4 wants ≥2 (set allow_single_provider=true to override)", + sc.internal_chain_id, + probes.len() + ); + } + if sources.insert(sc.internal_chain_id, SourceRoute { probes }).is_some() { + bail!("duplicate source chain config for internal_chain_id={}", sc.internal_chain_id); + } + } + Ok(Self { domain_sep, sources }) + } + + #[cfg(test)] + fn from_routes(domain_sep: Bytes32, routes: HashMap>>) -> Self { + Self { + domain_sep, + sources: routes.into_iter().map(|(k, probes)| (k, SourceRoute { probes })).collect(), + } + } +} + +#[async_trait] +impl SourceVerifier for RpcVerifier { + async fn verify_committed(&self, message: &CrossChainMessage) -> Result<(), VerifyError> { + let route = self + .sources + .get(&message.src_chain_id) + .ok_or(VerifyError::UnknownRoute(message.src_chain_id))?; + + let digest = message.digest(&self.domain_sep); + let not_committed = + VerifyError::NotCommitted { src_chain_id: message.src_chain_id, nonce: message.nonce }; + + // Every provider must independently return Final. Any Pending/NotFound is + // a refusal (fail closed); any transport error is Unavailable. A split + // vote (one Final, one NotFound) therefore refuses — safe by construction. + for probe in &route.probes { + match probe.check(&digest).await { + Ok(Commitment::Final) => {} + Ok(Commitment::Pending) | Ok(Commitment::NotFound) => return Err(not_committed), + Err(e) => { + return Err(VerifyError::Unavailable(format!("{}: {e:#}", probe.label()))) + } + } + } + Ok(()) + } +} + +/// Construct the configured verifier. In `environment = "dev"`, `trust_all` is +/// allowed (with a loud warning); anywhere else it is a hard error. `rpc` builds +/// an [`RpcVerifier`] from `[[source_chains]]`. +pub fn build( + mode: &str, + environment: &str, + domain_sep: Bytes32, + source_chains: &[SourceChainConfig], +) -> Result> { + match mode { + "trust_all" => { + if environment != "dev" { + bail!( + "source_verifier=trust_all is DEV ONLY (environment={environment}); \ + it disables the §5.4 boundary and lets the signer sign anything" + ); + } + warn!( + "source_verifier=trust_all — DEV ONLY, §5.4 source-commitment check is SKIPPED" + ); + Ok(Box::new(TrustAllVerifier)) + } + "rpc" => { + if source_chains.is_empty() { + bail!("source_verifier=rpc requires at least one [[source_chains]] entry"); + } + Ok(Box::new(RpcVerifier::from_config(domain_sep, source_chains)?)) + } + other => bail!("unknown source_verifier mode: {other}"), + } +} + +/// True if `internal_chain_id`'s family is one this node can verify. +pub fn is_supported_family(internal_chain_id: u32) -> bool { + let f = chain_id::family(internal_chain_id); + f == chain_id::FAMILY_EVM || f == chain_id::FAMILY_SUI +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_types::message::derive_domain_sep; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + const SALT: Bytes32 = [0x01; 32]; + const SUI_ID: u32 = 134_217_728; + const HYPER_ID: u32 = 268_436_454; + + /// Mock probe returning a fixed verdict (or an error), counting calls. + struct MockProbe { + name: String, + verdict: Result, + calls: Arc, + } + impl MockProbe { + fn new(name: &str, verdict: Result) -> (Box, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + ( + Box::new(MockProbe { name: name.into(), verdict, calls: calls.clone() }), + calls, + ) + } + } + #[async_trait] + impl CommitmentProbe for MockProbe { + fn label(&self) -> &str { + &self.name + } + async fn check(&self, _digest: &Bytes32) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.verdict.map_err(|_| anyhow::anyhow!("mock transport error")) + } + } + + fn msg(src: u32) -> CrossChainMessage { + CrossChainMessage::new(src, SUI_ID, 7, [0xab; 32], [0xcd; 32], b"hello-bridge".to_vec()) + } + + fn verifier(src: u32, probes: Vec>) -> RpcVerifier { + let mut routes: HashMap>> = HashMap::new(); + routes.insert(src, probes); + RpcVerifier::from_routes(derive_domain_sep(&SALT), routes) + } + + #[tokio::test] + async fn signs_when_all_providers_confirm_final() { + let (p1, _) = MockProbe::new("a", Ok(Commitment::Final)); + let (p2, _) = MockProbe::new("b", Ok(Commitment::Final)); + let v = verifier(HYPER_ID, vec![p1, p2]); + assert!(v.verify_committed(&msg(HYPER_ID)).await.is_ok()); + } + + #[tokio::test] + async fn rejects_unknown_route() { + let (p1, _) = MockProbe::new("a", Ok(Commitment::Final)); + let v = verifier(HYPER_ID, vec![p1]); + // message from SUI_ID, but only HYPER_ID is configured. + let err = v.verify_committed(&msg(SUI_ID)).await.unwrap_err(); + assert!(matches!(err, VerifyError::UnknownRoute(SUI_ID))); + } + + #[tokio::test] + async fn rejects_when_a_provider_reports_not_found() { + let (p1, _) = MockProbe::new("a", Ok(Commitment::Final)); + let (p2, _) = MockProbe::new("b", Ok(Commitment::NotFound)); + let v = verifier(HYPER_ID, vec![p1, p2]); + let err = v.verify_committed(&msg(HYPER_ID)).await.unwrap_err(); + assert!(matches!(err, VerifyError::NotCommitted { .. })); + } + + #[tokio::test] + async fn rejects_when_commitment_not_final() { + let (p1, _) = MockProbe::new("a", Ok(Commitment::Pending)); + let v = verifier(HYPER_ID, vec![p1]); + let err = v.verify_committed(&msg(HYPER_ID)).await.unwrap_err(); + assert!(matches!(err, VerifyError::NotCommitted { .. })); + } + + #[tokio::test] + async fn provider_disagreement_refuses() { + // one Final, one NotFound → refuse (fail closed), don't sign. + let (p1, _) = MockProbe::new("a", Ok(Commitment::Final)); + let (p2, _) = MockProbe::new("b", Ok(Commitment::NotFound)); + let v = verifier(HYPER_ID, vec![p1, p2]); + assert!(v.verify_committed(&msg(HYPER_ID)).await.is_err()); + } + + #[tokio::test] + async fn transport_error_is_unavailable() { + let (p1, _) = MockProbe::new("a", Ok(Commitment::Final)); + let (p2, _) = MockProbe::new("b", Err(())); + let v = verifier(HYPER_ID, vec![p1, p2]); + let err = v.verify_committed(&msg(HYPER_ID)).await.unwrap_err(); + assert!(matches!(err, VerifyError::Unavailable(_))); + } + + #[tokio::test] + async fn build_refuses_trust_all_outside_dev() { + assert!(build("trust_all", "prod", derive_domain_sep(&SALT), &[]).is_err()); + assert!(build("trust_all", "dev", derive_domain_sep(&SALT), &[]).is_ok()); + } + + #[tokio::test] + async fn build_rpc_requires_source_chains() { + assert!(build("rpc", "prod", derive_domain_sep(&SALT), &[]).is_err()); + } +} diff --git a/rust-backend/services/bridge-signer-service/tests/sign.rs b/rust-backend/services/bridge-signer-service/tests/sign.rs new file mode 100644 index 00000000..392f1e97 --- /dev/null +++ b/rust-backend/services/bridge-signer-service/tests/sign.rs @@ -0,0 +1,140 @@ +//! End-to-end tests of the async signing API: `POST /sign_requests` + +//! `GET /sign_requests/{hash}`, idempotent per hash, verify-before-admit. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{json, Value}; +use tower::ServiceExt; // oneshot + +use bridge_signer::ThresholdSigner; +use bridge_signer_service::ratelimit::RateLimiter; +use bridge_signer_service::router::public_router; +use bridge_signer_service::sessions::SessionStore; +use bridge_signer_service::state::AppState; +use bridge_signer_service::verifier::{SourceVerifier, TrustAllVerifier, VerifyError}; +use bridge_types::message::derive_domain_sep; +use bridge_types::{chain_id, CrossChainMessage}; + +/// A verifier that always refuses — to prove uncommitted messages 422 at the door. +struct RejectAllVerifier; +#[async_trait::async_trait] +impl SourceVerifier for RejectAllVerifier { + async fn verify_committed(&self, m: &CrossChainMessage) -> Result<(), VerifyError> { + Err(VerifyError::NotCommitted { src_chain_id: m.src_chain_id, nonce: m.nonce }) + } +} + +fn state_with(verifier: Box) -> Arc { + Arc::new(AppState { + signer: ThresholdSigner::from_seeds([0x42; 32], [0x11; 32]).unwrap(), + verifier, + domain_sep: derive_domain_sep(&[0x01; 32]), + ed25519_group_pubkey_id: 1, + ecdsa_group_pubkey_id: 2, + sessions: SessionStore::new(300, 1000), + rate_limiter: RateLimiter::new(60, 1000), + }) +} + +async fn post(app: axum::Router, uri: &str, body: Value) -> (StatusCode, Value) { + send(app, Request::builder().method("POST").uri(uri).header("content-type", "application/json").body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap()).await +} + +async fn get(app: axum::Router, uri: &str) -> (StatusCode, Value) { + send(app, Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()).await +} + +async fn send(app: axum::Router, req: Request) -> (StatusCode, Value) { + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) +} + +fn known_message() -> CrossChainMessage { + // HyperEVM → Sui, so the Ed25519 path is selected. + CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 998).unwrap(), + chain_id::encode(chain_id::FAMILY_SUI, 0).unwrap(), + 7, + [0xab; 32], + [0xcd; 32], + b"hello-bridge".to_vec(), + ) +} + +const KNOWN_HASH: &str = "0x535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707"; +const KNOWN_SIG: &str = "0x12bc85a949906a86bdea305aa6bc32ef704e77de62ea5fb65a3df3a39902e533\ +98ca95da28a3c34aa8187edcf8f6936330c94016e1e1c4d3f2f7b80027190001"; + +#[tokio::test] +async fn sign_request_completes_and_is_pollable() { + let state = state_with(Box::new(TrustAllVerifier)); + + // POST → 202, signed inline at M1, carries the known-vector signature. + let (status, body) = + post(public_router(state.clone()), "/sign_requests", json!({ "message": known_message() })).await; + assert_eq!(status, StatusCode::ACCEPTED); + assert_eq!(body["message_hash"], KNOWN_HASH); + assert_eq!(body["status"], "signed"); + assert_eq!(body["envelope"]["scheme_tag"], 0); // ED25519 + assert_eq!(body["envelope"]["signature"], KNOWN_SIG); + + // GET by hash returns the same completed session. + let (status, polled) = + get(public_router(state), &format!("/sign_requests/{KNOWN_HASH}")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(polled["status"], "signed"); + assert_eq!(polled["envelope"]["signature"], KNOWN_SIG); +} + +#[tokio::test] +async fn duplicate_post_coalesces_onto_one_session() { + let state = state_with(Box::new(TrustAllVerifier)); + let m = json!({ "message": known_message() }); + + let (s1, b1) = post(public_router(state.clone()), "/sign_requests", m.clone()).await; + let (s2, b2) = post(public_router(state), "/sign_requests", m).await; + assert_eq!(s1, StatusCode::ACCEPTED); + assert_eq!(s2, StatusCode::ACCEPTED); + // Both resolve to the same signed session (idempotent per hash). + assert_eq!(b1["envelope"]["signature"], b2["envelope"]["signature"]); + assert_eq!(b1["message_hash"], b2["message_hash"]); +} + +#[tokio::test] +async fn uncommitted_message_is_rejected_at_the_door() { + let state = state_with(Box::new(RejectAllVerifier)); + let (status, _) = + post(public_router(state.clone()), "/sign_requests", json!({ "message": known_message() })).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + // No session was cached — GET is a 404, so a later retry can re-admit. + let (get_status, _) = get(public_router(state), &format!("/sign_requests/{KNOWN_HASH}")).await; + assert_eq!(get_status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn unsupported_destination_family_is_bad_request() { + let state = state_with(Box::new(TrustAllVerifier)); + let message = CrossChainMessage::new( + chain_id::encode(chain_id::FAMILY_EVM, 1).unwrap(), + chain_id::encode(chain_id::FAMILY_SOLANA, 1).unwrap(), // no verifier here + 0, + [0u8; 32], + [0u8; 32], + vec![], + ); + let (status, _) = + post(public_router(state), "/sign_requests", json!({ "message": message })).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn get_unknown_hash_is_not_found() { + let state = state_with(Box::new(TrustAllVerifier)); + let (status, _) = get(public_router(state), "/sign_requests/0xdeadbeef").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/sui-bridge-contracts/DEPLOYMENTS.md b/sui-bridge-contracts/DEPLOYMENTS.md new file mode 100644 index 00000000..b418e8f0 --- /dev/null +++ b/sui-bridge-contracts/DEPLOYMENTS.md @@ -0,0 +1,159 @@ +# Bridge deployments + +## ✅ M2 round trip — LIVE both directions (2026-07-01) + +HyperEVM→Sui→HyperEVM completed on testnet, supply invariant holding: +1. HyperEVM `Locker.lock(1 tBTC)` → escrow 1e18; Outbox committed `0x8228fc…`. +2. Ed25519-signed (group id 1) → Sui `locker::bridge_receive` → **minted 1.00 WBTC**. +3. Sui `locker::bridge_out(1 WBTC)` → burned; Outbox committed `0x2449c1a3…`. +4. ECDSA-signed (group id 2) → HyperEVM `Inbox.receiveMessage` → **released 1 tBTC**, escrow 0. + +Both reconstructed digests matched the on-chain `messageHash`; both signatures verified +on the real Inboxes. + +**Relayer binary validated (2026-07-01).** Ran the actual `bridge-signer-service` + +`bridge-relayer` binaries against these live contracts: +- Signer live-verified the EVM commitment via `RpcVerifier`/`EvmProbe` and signed (202). +- The real `SuiDestSubmitter` write path delivered end-to-end: sui-sdk connect → on-chain + `getObject(dst_app)` type read → `parse_dispatch` → `bridge_receive` PTB → execute → + **minted 2.00 WBTC** (a fresh 2-tBTC lock). +- Two findings fixed/noted: (1) `EvmProbe` used an unbounded `eth_getLogs` range which + public RPCs reject ("max block range 1000") — fixed to a bounded `lookback_blocks` + window (default 1000); (2) the EVM *source* watcher hits intermittent HTTP 500 + (`ErrUpstreamsExhausted`) from the shared `rpcs.chain.link` endpoint under sustained + polling — a public-RPC rate limit, not a code bug; use a dedicated RPC in production. + +**Layer-2 + demo group keys (deployer `0xab8d…` on Sui, `0x303c…` on EVM):** + +| Object | Id | +|--------|----| +| Sui `sui_bridge` pkg (supersedes `0x6435…`) | `0xe403373522aa0dce645671bbd36ca1e80147c6418e1e4e08c97c8fa224a81253` | +| Sui Inbox / Outbox | `0xd35e063d1a1e5d6e5d57925523869cc86db61e164620f22d9d9e5bad77b9870d` / `0x779a01e6b2352fdb99340691699f21eb03cccfe9dec4419860adf23eb149f6df` | +| Sui GroupKeyRegistry (Ed25519 id 1 = `2152f8…`, seed `[0x42;32]`) | `0x39cce89c3c8b374b1a09922668394d555e11588e8e60d990db43c4835ced21a3` | +| Sui `locker` pkg / Locker`` (Mint) | `0x3ef9871fa5f93ac300317d3b240c85ee8f59f504de255e8e4c3f13b8d404160b` / `0x96e9a86b1c0585a49b5a75e13c2d77633bee4d1c9c31ef8659f1e708c788cae8` | +| Sui WBTC coin pkg | `0x6ef3c4764e35d35a4ca62d7aa4b96d2ee1360dfc23c073eca3ff2ec37b3507d3` | +| EVM Locker (Escrow) / test tBTC | `0x84E2e2C27217E10dF502cABD095421F9b364E098` / `0x8244B193a4a545D316c0eDe86c256A40dB0Ea439` | +| EVM group key id 2 (ECDSA `19e7…`, seed `[0x11;32]`) on Registry `0x676fBa34…` | — | + +> The demo uses group keys whose seeds we control (`[0x42;32]`/`[0x11;32]`) so a manual +> relay can sign; the ticket-01 group keys (`0x40cc…`/`0x6B908C…`) have external seeds. + +--- + + +## Domain separator (ticket 01) + +Digest is `keccak256(DOMAIN_SEP || encode(message))`, `DOMAIN_SEP = +keccak256("XCHAIN_MSG_V1" || deployment_salt)` (spec §2.2). The **deployment +salt for this testnet deployment** is: + +``` +deployment_salt = keccak256("sui-options-bridge:testnet:2026-07") + = 0x857bf91867252dcae83dfed125aa1f1862c15fb15aba78b499e3fb72cfaabc8a +``` + +This exact 32-byte value MUST be passed to the Sui `inbox::create`/`outbox::create`, +the Solidity `Inbox`/`Outbox` constructors (`DEPLOYMENT_SALT`), and both services +(`deployment_salt_hex` → `BRIDGE_DEPLOYMENT_SALT`). Cross-language parity is +locked by the shared test vectors (test salt `0x01*32`, digest +`0x535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707`). + +On-chain `DOMAIN_SEP` for this salt (derived on-chain by both chains): +`0x734dccf071e185b986c6693b02fba9371d89a10fca38cb4e73793ca8607fd1dc`. + +## Sui testnet — REDEPLOYED 2026-07-01 (domain-separated, ticket 01) + +Fresh publish (the `message::hash` signature changed → upgrade-incompatible). +Deployer / governance / guardian: `0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865` +Publish digest: `HWBj2wrY5QcsYkoNfugC69e5hK2mQQKwfHCtwcA2eb84` + +| Object | Id | +|--------|----| +| Package | `0x6435311f4d8891f7392cadcc3cc503e71757ba3d24f043f5c04733da7ae6b000` | +| ChainRegistry (shared) | `0x75789ceda6f51224483e5d1f1dfd40f70cd085439aa943883a5ccc56557a0d22` | +| GroupKeyRegistry (shared) | `0x16ffe9d907a9bd1bd274cc8b48bc3092ae3546f9e9d2c57e7841984468856144` | +| GovernanceCap | `0xe241c3a8bdd77a1883434eda223ed1f1ac12042902b3a4bf557cb8692d437d81` | +| GuardianCap | `0xc2f4c83325ecf5ff3bebd3e085202ff2a9775d93e11c928e33ae4879c4ac7c46` | +| UpgradeCap | `0xb0096a1cc730608cd6b703d999d94e75114b02450d27b6d3b00480e62eee98aa` | +| Inbox (dst=Sui, shared) | `0x32c3cfe0571167002fc386d7bae00a6d761ada54f70dcf4e8e18ee615c230250` | +| Outbox (src=Sui, shared) | `0x0e505016a6b46226b203506827a93e52f780f79c87bce8d80f94143b6e8a6431` | + +Wiring: Sui (`134217728`, finality 1) + HyperEVM (`268436454`, finality 0/12, +EVM addrs zeroed pending the EVM redeploy) registered; Ed25519 group key id `1` = +`0x40cc5cb8a797c03eece3e93b09243c6bff29346def1020c8fdce6f6b17b0be3e`. +On-chain smoke: Inbox + Outbox `domain_sep` both read back = the expected +`0x734dcc…d1dc`. The 2026-06-29 Sui deployment below is **superseded**. + +## HyperEVM testnet — REDEPLOYED 2026-07-01 (domain-separated, ticket 01) + +Broadcast via the Chainlink first-party RPC `https://rpcs.chain.link/hyperevm/testnet` +(the canonical `rpc.hyperliquid-testnet.xyz` is blocked by an upstream SNI egress +filter that injects `ff ff ff ff ff` at the TLS handshake — see the investigation +notes; the endpoint/URL are correct, the path is filtered). +Deployer / governance / guardian: `0x303c0af404a4444c3224aaF2628988940C6D5705` + +| Contract | Address | +|----------|---------| +| Registry | `0x676fBa345f0e5dB7931AdB214d73B3A1989A0fD2` | +| Inbox (dst=HyperEVM) | `0xD4524ce4b234c24B156631Ca612EC387de39968C` | +| Outbox (src=HyperEVM) | `0x1797FAa1eAF0cc1fC7C092Db0035A3c46A357ff6` | + +On-chain verification (via cast): both `Inbox.domainSep()` and `Outbox.domainSep()` += `0x734dcc…d1dc` — **byte-identical to the live Sui contracts**, so one threshold +signature's digest domain matches on both chains. Group key id `1` = ECDSA +`0x6B908C2c00C2C99865301b112e04550a412b421e`. `Inbox.dstChainId()` = 268436454. + +Registry wiring: HyperEVM (`268436454`, finality 0/12) + Sui (`134217728`, +finality 1/0) registered; the Sui entry carries the **new** Sui Outbox/Inbox +object ids (passed via `SUI_OUTBOX`/`SUI_INBOX`). + +> **Follow-up (ticket 02):** the *Sui* ChainRegistry's HyperEVM entry was +> registered with zero outbox/inbox addrs (the EVM addresses didn't exist yet). +> `registry.move` has no `update_chain`, so backfilling the real EVM addresses +> above needs a small governance function added there (the source verifier in +> ticket 02 needs the EVM Outbox address to verify EVM→Sui commitments). + + +## Sui testnet — deployed 2026-06-29 + +Deployer / governance / guardian: `0xab8d1b5a5311c9400e3eaf5c3b641f10fb48b43cc30d365fa8a98a6ca6bd4865` +Publish digest: `BcWbRxsj1ZsSSTSc1pAfEbnZzzDCususzYqa81B8EjXY` + +| Object | Id | +|--------|----| +| Package | `0x60abcb3006916a853bc7e51abe28e6c27beff659112363cea747a73e6b5d7eb8` | +| ChainRegistry (shared) | `0xba290f44421b8d056ee7c3cc4496c24cf257a6d445c5115d4ca2f18d5d160e20` | +| GroupKeyRegistry (shared) | `0x74ad3c2d056e0f9d1f31c4510005c12950faf5955686791168326bf782fc95fb` | +| GovernanceCap | `0x7b0be8902bb9356b41f2a72cbc24752ce1fa4205959bc911275186991ee6176c` | +| GuardianCap | `0xb96f1538548eebc221cee11e96a5409c78ecce195a5c6fd125d8e9543e4ef002` | +| UpgradeCap | `0x2b953d865611c213b1845c7142136834ab35e5b22c359e0cbc79fc2d576e8c16` | +| Inbox (dst=Sui, shared) | `0x7934aa71a9a3ebd1099fe294b8a24b47d3b78c4cc5969b7ba0a383d73c7e0e1d` | +| Outbox (src=Sui, shared) | `0x3989143acf84f6fcf899b2004eba27e71f5b12a019569c56d7f1604d18b1ec66` | + +Registry wiring: +- Sui chain registered: internal id `134217728` (= family 1 `<< 27 | 0`), finality kind 1. +- HyperEVM chain registered: internal id `268436454`, finality kind 0 / value 12, + outbox/inbox = the EVM addresses below (left-padded to 32 bytes). +- Group key id `1`: Ed25519, pubkey `0x40cc5cb8a797c03eece3e93b09243c6bff29346def1020c8fdce6f6b17b0be3e`. + +## HyperEVM testnet — deployed 2026-06-29 + +Chain: HyperEVM testnet (chainId 998). Internal id `268436454` (= family 2 `<< 27 | 998`). +Deployer / governance / guardian: `0x303c0af404a4444c3224aaF2628988940C6D5705` + +| Contract | Address | +|----------|---------| +| Registry | `0x375D5CE9772ea59Ee58B62ec2E25c072872a7401` | +| Inbox (dst=HyperEVM) | `0xA2b0dA5F12628f1FDC1E517DF33F5C3fF528bF74` | +| Outbox (src=HyperEVM) | `0xbCE58f862011C83DA87b6061e3B8bCf3d1767051` | + +Registry wiring: +- HyperEVM chain registered (`268436454`), Sui chain registered (`134217728`). +- Group key id `1`: ECDSA, address `0x6B908C2c00C2C99865301b112e04550a412b421e`. + +## M1 signer keys (TESTNET ONLY) + +Seeds live in `solidity/.env` (gitignored) and the signer-service config. The +same group keys are registered on both chains under **id 1**: +- Ed25519 (Sui): pubkey `0x40cc5cb8a797c03eece3e93b09243c6bff29346def1020c8fdce6f6b17b0be3e` +- ECDSA (EVM): address `0x6B908C2c00C2C99865301b112e04550a412b421e` diff --git a/sui-bridge-contracts/README.md b/sui-bridge-contracts/README.md new file mode 100644 index 00000000..f0ed920a --- /dev/null +++ b/sui-bridge-contracts/README.md @@ -0,0 +1,17 @@ +# sui-bridge-contracts + +On-chain contracts for the cross-chain bridge ([`../bridge-spec.md`](../bridge-spec.md)), +one subfolder per chain family. + +| Folder | Stack | Status | +|--------|-------|--------| +| [`sui/`](sui) | Move (`sui move build` / `sui move test`) | Layer 1 messaging — implemented + deployed (testnet) | +| [`solidity/`](solidity) | Foundry (HyperEVM) | Layer 1 messaging — implemented + deployed; Layer 2 `TransferPayload` codec | +| [`sui-locker/`](sui-locker) | Move | Layer 2 Locker (lock-and-mint) — implemented | + +Both sides must agree on the **canonical message encoding** and **keccak256 +digest** defined in [`sui/sources/message.move`](sui/sources/message.move) so a +single threshold signature verifies on either chain — the Solidity `Outbox`/ +`Inbox` will reproduce that exact byte layout and the +`(family << 27) | chain_id` internal-id scheme from +[`sui/sources/chain_id.move`](sui/sources/chain_id.move). diff --git a/sui-bridge-contracts/enclave/.gitignore b/sui-bridge-contracts/enclave/.gitignore new file mode 100644 index 00000000..33763f06 --- /dev/null +++ b/sui-bridge-contracts/enclave/.gitignore @@ -0,0 +1,13 @@ +# Move build artifacts +build/ + +# Move coverage / trace output +*.mvcov +.coverage_map.mvcov +.trace + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/sui-bridge-contracts/enclave/Move.lock b/sui-bridge-contracts/enclave/Move.lock new file mode 100644 index 00000000..f3a12f5a --- /dev/null +++ b/sui-bridge-contracts/enclave/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.bridge_enclave] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sui-bridge-contracts/enclave/Move.toml b/sui-bridge-contracts/enclave/Move.toml new file mode 100644 index 00000000..80598517 --- /dev/null +++ b/sui-bridge-contracts/enclave/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "bridge_enclave" +version = "0.0.1" +edition = "2024.beta" + +# On-chain Nitro-enclave registry for the bridge signer (bridge-spec.md §5, +# bridge_tickets/07 Phase 3). Adapted from MystenLabs/nautilus +# move/enclave/sources/enclave.move (Apache-2.0); the attestation verification +# itself is native in the Sui framework (`sui::nitro_attestation`). + +[dependencies] diff --git a/sui-bridge-contracts/enclave/Published.toml b/sui-bridge-contracts/enclave/Published.toml new file mode 100644 index 00000000..82e2e092 --- /dev/null +++ b/sui-bridge-contracts/enclave/Published.toml @@ -0,0 +1,12 @@ +# Generated by Move +# This file contains metadata about published versions of this package in different environments +# This file SHOULD be committed to source control + +[published.testnet] +chain-id = "4c78adac" +published-at = "0xeda4ddd012c724e1fdcf8c69abdf3d365a6b52448846ccf8098d011e037cc466" +original-id = "0xeda4ddd012c724e1fdcf8c69abdf3d365a6b52448846ccf8098d011e037cc466" +version = 1 +toolchain-version = "1.71.1" +build-config = { flavor = "sui", edition = "2024" } +upgrade-capability = "0xf4fc772617e4d61850f194ce113daeb830cddb7043110b7d8ddfadd4773902e8" diff --git a/sui-bridge-contracts/enclave/sources/enclave.move b/sui-bridge-contracts/enclave/sources/enclave.move new file mode 100644 index 00000000..6167d856 --- /dev/null +++ b/sui-bridge-contracts/enclave/sources/enclave.move @@ -0,0 +1,219 @@ +// Adapted from MystenLabs/nautilus move/enclave/sources/enclave.move +// (Copyright (c) Mysten Labs, SPDX-License-Identifier: Apache-2.0). +// +// On-chain registry of attested Nitro enclaves (bridge_tickets/07 Phase 3). The +// AWS Nitro attestation itself is verified natively by the Sui framework +// (`sui::nitro_attestation`); this module only compares the attested PCRs to an +// approved `EnclaveConfig` and records the enclave's ephemeral pubkey. +// +// Bridge additions over the upstream reference: +// - `update_enclave_pk`: re-register a fresh ephemeral key into the SAME +// `Enclave` object (stable object id), so the ticket-08 Seal policy — which +// binds a node's share to its `Enclave` object id — survives an enclave +// restart/replacement (spec §6.4). Owner-gated + on-chain-visible (event). +// - Events on register/update, for the ticket-10 alerting (an anomalous +// re-registration is an attack signal). +module bridge_enclave::enclave; + +use std::bcs; +use std::string::String; +use sui::ed25519; +use sui::event; +use sui::nitro_attestation::NitroAttestationDocument; + +use fun to_pcrs as NitroAttestationDocument.to_pcrs; + +const EInvalidPCRs: u64 = 0; +const EInvalidConfigVersion: u64 = 1; +const EInvalidCap: u64 = 2; +const EInvalidOwner: u64 = 3; + +/// PCR0 = enclave image (EIF), PCR1 = kernel, PCR2 = application. +public struct Pcrs(vector, vector, vector) has copy, drop, store; + +/// The approved measurements a registering enclave must match. Governance holds +/// the `Cap` and bumps `version` when the PCRs change (e.g. an approved rebuild). +public struct EnclaveConfig has key { + id: UID, + name: String, + pcrs: Pcrs, + capability_id: ID, + version: u64, +} + +/// A verified enclave instance and its boot-fresh ephemeral pubkey. One per +/// signer node; its object id is the identity the Seal policy binds to (§6.5). +public struct Enclave has key { + id: UID, + pk: vector, + config_version: u64, + owner: address, +} + +/// Capability to edit the `EnclaveConfig` (PCRs/name). Held by governance. +public struct Cap has key, store { + id: UID, +} + +public struct IntentMessage has copy, drop { + intent: u8, + timestamp_ms: u64, + payload: T, +} + +// --- events (ticket-10 alerting) --- +public struct EnclaveRegistered has copy, drop { + enclave_id: ID, + config_version: u64, + owner: address, +} +public struct EnclavePkUpdated has copy, drop { + enclave_id: ID, + owner: address, + config_version: u64, +} + +/// Create a governance `Cap` from a module witness `T`. +public fun new_cap(_: T, ctx: &mut TxContext): Cap { + Cap { id: object::new(ctx) } +} + +public fun create_enclave_config( + cap: &Cap, + name: String, + pcr0: vector, + pcr1: vector, + pcr2: vector, + ctx: &mut TxContext, +) { + transfer::share_object(EnclaveConfig { + id: object::new(ctx), + name, + pcrs: Pcrs(pcr0, pcr1, pcr2), + capability_id: cap.id.to_inner(), + version: 0, + }); +} + +/// Permissionlessly register an enclave whose attestation matches the config. +/// The registrant (`ctx.sender()`) becomes the node's operator/owner. +public fun register_enclave( + enclave_config: &EnclaveConfig, + document: NitroAttestationDocument, + ctx: &mut TxContext, +) { + let pk = enclave_config.load_pk(&document); + let enclave = Enclave { + id: object::new(ctx), + pk, + config_version: enclave_config.version, + owner: ctx.sender(), + }; + event::emit(EnclaveRegistered { + enclave_id: object::id(&enclave), + config_version: enclave.config_version, + owner: enclave.owner, + }); + transfer::share_object(enclave); +} + +/// Re-register a fresh ephemeral key into an EXISTING `Enclave` object after a +/// restart/replacement — keeping the object id stable so the Seal share binding +/// (§6.5/§6.4) still resolves. Owner-gated; re-verifies a fresh attestation. +public fun update_enclave_pk( + enclave: &mut Enclave, + enclave_config: &EnclaveConfig, + document: NitroAttestationDocument, + ctx: &mut TxContext, +) { + assert!(enclave.owner == ctx.sender(), EInvalidOwner); + enclave.pk = enclave_config.load_pk(&document); + enclave.config_version = enclave_config.version; + event::emit(EnclavePkUpdated { + enclave_id: object::id(enclave), + owner: enclave.owner, + config_version: enclave.config_version, + }); +} + +public fun verify_signature( + enclave: &Enclave, + intent_scope: u8, + timestamp_ms: u64, + payload: P, + signature: &vector, +): bool { + let intent_message = create_intent_message(intent_scope, timestamp_ms, payload); + let bytes = bcs::to_bytes(&intent_message); + ed25519::ed25519_verify(signature, &enclave.pk, &bytes) +} + +public fun update_pcrs( + config: &mut EnclaveConfig, + cap: &Cap, + pcr0: vector, + pcr1: vector, + pcr2: vector, +) { + cap.assert_is_valid_for_config(config); + config.pcrs = Pcrs(pcr0, pcr1, pcr2); + config.version = config.version + 1; +} + +public fun update_name(config: &mut EnclaveConfig, cap: &Cap, name: String) { + cap.assert_is_valid_for_config(config); + config.name = name; +} + +// --- views --- +public fun pcr0(config: &EnclaveConfig): &vector { &config.pcrs.0 } +public fun pcr1(config: &EnclaveConfig): &vector { &config.pcrs.1 } +public fun pcr2(config: &EnclaveConfig): &vector { &config.pcrs.2 } +public fun config_version(config: &EnclaveConfig): u64 { config.version } +public fun pk(enclave: &Enclave): &vector { &enclave.pk } +public fun owner(enclave: &Enclave): address { enclave.owner } +public fun enclave_config_version(enclave: &Enclave): u64 { enclave.config_version } + +/// Retire an enclave whose config version is stale (post-rotation cleanup). +public fun destroy_old_enclave(e: Enclave, config: &EnclaveConfig) { + assert!(e.config_version < config.version, EInvalidConfigVersion); + let Enclave { id, .. } = e; + id.delete(); +} + +public fun destroy_enclave_by_owner(e: Enclave, ctx: &mut TxContext) { + assert!(e.owner == ctx.sender(), EInvalidOwner); + let Enclave { id, .. } = e; + id.delete(); +} + +fun assert_is_valid_for_config(cap: &Cap, enclave_config: &EnclaveConfig) { + assert!(cap.id.to_inner() == enclave_config.capability_id, EInvalidCap); +} + +fun load_pk(enclave_config: &EnclaveConfig, document: &NitroAttestationDocument): vector { + assert!(document.to_pcrs() == enclave_config.pcrs, EInvalidPCRs); + (*document.public_key()).destroy_some() +} + +fun to_pcrs(document: &NitroAttestationDocument): Pcrs { + let pcrs = document.pcrs(); + Pcrs(*pcrs[0].value(), *pcrs[1].value(), *pcrs[2].value()) +} + +public fun create_intent_message(intent: u8, timestamp_ms: u64, payload: P): IntentMessage

{ + IntentMessage { intent, timestamp_ms, payload } +} + +// --- test-only constructors (the attestation path needs a real doc + hardware, +// so unit tests build the Enclave directly to exercise the registry logic) --- +#[test_only] +public fun deploy_for_testing(pk: vector, config_version: u64, ctx: &mut TxContext): Enclave { + Enclave { id: object::new(ctx), pk, config_version, owner: ctx.sender() } +} + +#[test_only] +public fun destroy(enclave: Enclave) { + let Enclave { id, .. } = enclave; + id.delete(); +} diff --git a/sui-bridge-contracts/enclave/sources/signer.move b/sui-bridge-contracts/enclave/sources/signer.move new file mode 100644 index 00000000..c8053176 --- /dev/null +++ b/sui-bridge-contracts/enclave/sources/signer.move @@ -0,0 +1,22 @@ +/// The bridge signer's instantiation of the generic enclave registry +/// (bridge_tickets/07). `BRIDGE_SIGNER` is the witness type that parameterizes +/// this signer's `EnclaveConfig`/`Enclave`/`Cap`, so only this module can mint +/// the governance capability. +module bridge_enclave::signer; + +use bridge_enclave::enclave; + +public struct BRIDGE_SIGNER has drop {} + +/// On publish, mint the governance `Cap` for the bridge signer's enclave config +/// and hand it to the deployer (→ a governance multisig per ticket 10). Governance +/// then calls `enclave::create_enclave_config` with the approved PCRs, and each +/// node operator permissionlessly `register_enclave`s its attested instance. +fun init(ctx: &mut TxContext) { + transfer::public_transfer(enclave::new_cap(BRIDGE_SIGNER {}, ctx), ctx.sender()); +} + +#[test_only] +public fun init_for_testing(ctx: &mut TxContext) { + init(ctx) +} diff --git a/sui-bridge-contracts/enclave/tests/enclave_tests.move b/sui-bridge-contracts/enclave/tests/enclave_tests.move new file mode 100644 index 00000000..f1063a82 --- /dev/null +++ b/sui-bridge-contracts/enclave/tests/enclave_tests.move @@ -0,0 +1,125 @@ +#[test_only] +module bridge_enclave::enclave_tests; + +use sui::test_scenario::{Self as ts}; +use bridge_enclave::enclave::{Self, Cap, EnclaveConfig}; + +const GOV: address = @0xA; +const OPERATOR: address = @0xB; + +/// Test witness (stands in for BRIDGE_SIGNER; a foreign test module can't mint +/// the real one, which is the point of the witness pattern). +public struct WITNESS has drop {} + +fun pcr(b: u8): vector { + let mut v = vector[]; + let mut i = 0u64; + while (i < 48) { v.push_back(b); i = i + 1; }; // Nitro PCRs are SHA-384 (48 bytes) + v +} + +/// Create a config owned by GOV, leaving the scenario holding the Cap + shared config. +fun setup(s: &mut ts::Scenario) { + let cap = enclave::new_cap(WITNESS {}, s.ctx()); + enclave::create_enclave_config(&cap, b"bridge-signer".to_string(), pcr(0), pcr(1), pcr(2), s.ctx()); + transfer::public_transfer(cap, GOV); + s.next_tx(GOV); +} + +#[test] +fun pcrs_update_bumps_version() { + let mut s = ts::begin(GOV); + setup(&mut s); + let cap = s.take_from_sender>(); + let mut config = s.take_shared>(); + + assert!(enclave::config_version(&config) == 0, 0); + assert!(*enclave::pcr0(&config) == pcr(0), 1); + enclave::update_pcrs(&mut config, &cap, pcr(9), pcr(9), pcr(9)); + assert!(enclave::config_version(&config) == 1, 2); // bumped + assert!(*enclave::pcr0(&config) == pcr(9), 3); + + ts::return_shared(config); + s.return_to_sender(cap); + s.end(); +} + +#[test] +#[expected_failure(abort_code = 2, location = bridge_enclave::enclave)] // EInvalidCap +fun update_pcrs_rejects_foreign_cap() { + let mut s = ts::begin(GOV); + setup(&mut s); + let mut config = s.take_shared>(); + // A different cap (fresh id) is not the one the config was created with. + let foreign = enclave::new_cap(WITNESS {}, s.ctx()); + enclave::update_pcrs(&mut config, &foreign, pcr(9), pcr(9), pcr(9)); + + transfer::public_transfer(foreign, GOV); + ts::return_shared(config); + s.end(); +} + +#[test] +fun update_enclave_pk_keeps_object_id_and_is_owner_gated_shape() { + // The register/update paths take a NitroAttestationDocument (framework-native, + // needs a real doc + hardware) — not constructable in a Move test. Here we + // exercise the object-stability + owner fields via the test-only constructor. + let mut s = ts::begin(OPERATOR); + let e = enclave::deploy_for_testing(x"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12", 0, s.ctx()); + assert!(enclave::owner(&e) == OPERATOR, 0); + assert!(enclave::enclave_config_version(&e) == 0, 1); + enclave::destroy(e); + s.end(); +} + +#[test] +fun verify_signature_rejects_bad_sig() { + let mut s = ts::begin(OPERATOR); + let e = enclave::deploy_for_testing(pcr(0), 0, s.ctx()); + // A garbage 64-byte signature must not verify. + let bad = pcr(7); // 48 bytes, wrong length/content → false, not an abort + assert!(!enclave::verify_signature(&e, 0u8, 1_700_000_000_000u64, b"payload", &bad), 0); + enclave::destroy(e); + s.end(); +} + +#[test] +fun old_enclave_destroyable_after_rotation() { + let mut s = ts::begin(GOV); + setup(&mut s); + let cap = s.take_from_sender>(); + let mut config = s.take_shared>(); + enclave::update_pcrs(&mut config, &cap, pcr(9), pcr(9), pcr(9)); // config → version 1 + + // An enclave pinned to version 0 is now stale and can be retired. + let stale = enclave::deploy_for_testing(pcr(0), 0, s.ctx()); + enclave::destroy_old_enclave(stale, &config); + + ts::return_shared(config); + s.return_to_sender(cap); + s.end(); +} + +#[test] +#[expected_failure(abort_code = 1, location = bridge_enclave::enclave)] // EInvalidConfigVersion +fun current_enclave_not_destroyable_as_old() { + let mut s = ts::begin(GOV); + setup(&mut s); + let config = s.take_shared>(); + // config is at version 0; an enclave also at version 0 is not "old". + let current = enclave::deploy_for_testing(pcr(0), 0, s.ctx()); + enclave::destroy_old_enclave(current, &config); + ts::return_shared(config); + s.end(); +} + +#[test] +fun init_mints_governance_cap() { + let mut s = ts::begin(GOV); + bridge_enclave::signer::init_for_testing(s.ctx()); + s.next_tx(GOV); + // The deployer received a Cap. + let cap = s.take_from_sender>(); + s.return_to_sender(cap); + s.end(); +} diff --git a/sui-bridge-contracts/relayer-dispatch-design.md b/sui-bridge-contracts/relayer-dispatch-design.md new file mode 100644 index 00000000..bb7a3e58 --- /dev/null +++ b/sui-bridge-contracts/relayer-dispatch-design.md @@ -0,0 +1,212 @@ +# Relayer dispatch — design deep-dive & fix plan + +**Problem:** a generic relayer can submit to the EVM Inbox for any app, but +cannot submit to the Sui Inbox without app-specific knowledge. Why, and how do +we make one relayer relay *all* transactions? + +--- + +## 1. Deep dive: where the spec's dispatch model breaks + +### 1.1 What the spec assumes +The spec (§2.5 step 8, §3.2 step 7–8, §3.3 step 7–8) describes delivery as: + +> Inbox … **dispatches the payload to message.dst_app via app callback** … +> Locker(Sui).onReceive(payload) mints … + +This is a single mental model: **the Inbox synchronously calls the destination +app by address.** That model is *EVM-shaped*. It is correct for EVM and quietly +impossible for Sui. + +### 1.2 Why it works on EVM +EVM has **dynamic dispatch** and **address-rooted storage**: + +```solidity +IMessageRecipient(dstApp).onReceive(srcChainId, srcApp, payload); +``` + +The Inbox holds only `dstApp` (an address) and calls it. The app reads *its own* +storage by address — the caller supplies no app state. So the relayer needs to +know **nothing** about the app: it just calls `Inbox.receiveMessage(message, +envelope)` and the Inbox fans out. **The EVM relayer is fully generic.** (We've +proven this end-to-end on anvil.) + +### 1.3 Why it cannot work on Sui +Move/Sui has **no dynamic dispatch** and an **explicit-object argument model**: + +- A package can only call functions it imported at compile time. There is no + `call(address, function, args)`. The Inbox literally cannot name an arbitrary + `dst_app`'s code. +- Functions operate on **objects passed in as arguments**. To mint, the Locker + needs its own shared object (and `TreasuryCap`, `Clock`, …) passed *as call + arguments*. Storage is not address-rooted; it's object-rooted and must be + supplied by the transaction. + +The consequence is that **control flow must invert**. On EVM the Inbox calls the +app; on Sui the **app must be the entry point and call *into* the Inbox** to +verify, then act on its own objects: + +``` +// EVM: relayer → Inbox.receiveMessage → (Inbox calls) app.onReceive +// Sui: relayer → app.bridge_receive → (app calls) inbox::receive + inbox::consume → app effects +``` + +Our current `Inbox.receive → DeliveredMessage (hot potato) → consume(&UID)` API +is the *correct* Move shape for this. The hot potato forces the app to discharge +the message (replay-safe, atomic), and `&UID` proves the caller is the real +`dst_app`. Nothing is wrong with the contracts. + +**The real gap is off-chain:** to build the Sui delivery transaction, the relayer +must name the app's function and pass the app's objects. That information is +app-specific, so a *naively* generic relayer can't construct it. + +### 1.4 This is intrinsic, not a bug +This is how every Move bridge works. Wormhole on Sui returns a verified-VAA hot +potato; the Token Bridge app is the entry that consumes it, and Wormhole's +relayers are **app-aware on Sui** (there is no generic on-chain relayer for Sui +delivery — only on EVM). So your instinct ("each app needs app-specific relay") +is correct in direction. The goal is to **shrink that per-app surface to almost +nothing** so one relayer process still serves every app. + +--- + +## 2. Design target + +One relayer **process/codebase** that relays everything: + +- **EVM destinations:** fully generic (already true). +- **Sui destinations:** generic for apps that follow a **standard receive + convention** — zero per-app code. A small escape hatch for non-standard apps. + +The trust model is untouched: the relayer stays untrusted, every message +self-verifies on-chain, and a wrong dispatch just produces a reverting tx. + +--- + +## 3. The fix + +### 3.1 On-chain: a standard Sui "bridge-receive" convention +Define one canonical entry shape that all standard Locker-style apps implement: + +```move +// In the app module that also defines the app object's type. +public fun bridge_receive( + inbox: &mut Inbox, + keys: &GroupKeyRegistry, + self: &mut Locker, // the dst_app object + message: vector, // BCS of CrossChainMessage + envelope: vector, // BCS of SignatureEnvelope + clock: &Clock, + ctx: &mut TxContext, +) { + let m = message::from_bcs(message); + let env = envelope::from_bcs(envelope); + let delivered = inbox::receive(inbox, keys, m, env); + let (src_chain, src_app, payload) = inbox::consume(inbox, delivered, &self.id); + // app checks src_app == registered peer, decodes payload, mints, rate-limits. +} +``` + +Key properties: +- **Fixed positional signature** → the relayer builds args without per-app code. +- The app does `receive` + `consume` + effects internally → **one MoveCall** per + delivery. +- `message`/`envelope` passed as BCS bytes so the relayer supplies plain `vector` + args (no need to construct Move structs in the PTB). Needs small + `message::from_bcs` / `envelope::from_bcs` helpers (L1 addition). +- **No L1 contract change** beyond those two helper constructors — the existing + `receive`/`consume` already support this. + +### 3.2 On-chain: convention-over-configuration discovery (the elegant part) +The relayer does **not** need a per-app config to find the call target. From the +message it has `dst_app` (an object id). It can: + +1. `getObject(dst_app)` → the object's type `0xPKG::locker::Locker`. +2. Derive `(package = 0xPKG, module = locker)` from the type. +3. Assume the standard function name `bridge_receive`. +4. Build `0xPKG::locker::bridge_receive(inbox, keys, dst_app, message, envelope, clock)`. + +So a **standard app needs zero relayer configuration** — its on-chain type *is* +the dispatch descriptor. New apps "just work" if they follow the convention. + +### 3.3 On-chain (optional): descriptor registry for non-standard apps +Apps that need extra objects (an oracle, a second treasury) or a non-standard +module/function register a descriptor in a shared registry: + +``` +DeliveryRegistry[dst_app] = { + package, module, function, + extra_objects: vector, // appended after clock, in order + mutability: vector, +} +``` + +Untrusted: a bad descriptor only yields a failing tx. The relayer reads it when +present, else falls back to the §3.2 convention. + +### 3.4 Off-chain: relayer becomes a family-routed dispatcher +Generalize today's single `DestSubmitter` into routing by destination: + +``` +relay_message(message, signer): + envelope = signer.sign(message) + submitter = router.for_chain(message.dst_chain_id) // by family + submitter.submit(message, envelope) +``` + +- `EvmSubmitter` (per EVM chain) — exactly today's `EvmDestSubmitter`, generic. +- `SuiSubmitter` (per Sui chain) — **new**, generic: + 1. Resolve `(package, module, function)` from `dst_app` type (§3.2) or registry (§3.3). + 2. Resolve shared-object args (`Inbox`, `GroupKeyRegistry` from the chain + registry; `dst_app`; `Clock` = `0x6`; any extras) → fetch + `initial_shared_version` + mutability via RPC. + 3. BCS-encode `message`/`envelope` to the Move struct layout (add + `bridge_types::to_move_bcs`). + 4. Build the `MoveCall` PTB, sign with the relayer's Sui key, submit. + +Both submitters implement the same `DestSubmitter` trait, so the orchestration, +dedup (`is_delivered` → `inbox::is_consumed`), and retry loop are unchanged. + +### 3.5 Off-chain: escape hatch for exotic apps +A `SuiCustomAdapter` trait keyed by `dst_app` (or app type) lets a truly unusual +app ship a small Rust PTB-builder plugged into the same router. This is the only +case that resembles "an app-specific relayer," and it's a ~30-line adapter, not a +new process. + +--- + +## 4. What this means for "app-specific relayers" +Your realization, refined: + +| App shape | Relayer work needed | +|---|---| +| Standard Locker (EVM dst) | none — generic | +| Standard Locker (Sui dst), follows `bridge_receive` convention | **none** — type-derived dispatch | +| Sui app needing extra shared objects | one on-chain descriptor row (no relayer code) | +| Sui app with exotic PTB needs | a small `SuiCustomAdapter` (Rust), same process | + +So you do **not** run a relayer per app. One relayer relays everything; apps pay +a convention (a standard entry function) instead of a bespoke relayer. + +--- + +## 5. Sequencing +- **No new L1 milestone.** This is M2 (Locker) work. +- **L1 contract changes:** tiny — add `message::from_bcs` / `envelope::from_bcs` + (Move) and `bridge_types::to_move_bcs` (Rust). The `Inbox` API is unchanged. +- **M2 deliverables gain:** the `bridge_receive` convention in the Locker, the + generic `SuiSubmitter`, and the family router in the relayer. The EVM submitter + is already done and stays. +- **Result:** the relayer relays both directions for the Locker (and any + convention-following app) end-to-end. + +## 6. Alternatives considered (and why not) +- **Per-app off-chain config map** (`dst_app → call target`): simpler than a + registry but needs reconfiguring the relayer for every new app. The + type-derived convention (§3.2) is strictly better and free. +- **Verify-and-store "mailbox"** (`inbox::verify` stores the message; app pulls + later): makes the *relayer* generic but it no longer *delivers* — the app must + run its own keeper to pull, which just relocates the app-awareness and adds a + second tx + storage. Doesn't meet "relayer delivers everything." +- **On-chain generic dispatch on Sui:** impossible — Move has no dynamic + dispatch or function pointers across packages. Confirmed dead end. diff --git a/sui-bridge-contracts/solidity/.env.example b/sui-bridge-contracts/solidity/.env.example new file mode 100644 index 00000000..6b514bc0 --- /dev/null +++ b/sui-bridge-contracts/solidity/.env.example @@ -0,0 +1,20 @@ +# Copy to .env and fill in. `.env` is gitignored. + +# Deployer key (also the temporary governance during wiring). +PRIVATE_KEY=0x... + +# HyperEVM testnet RPC (used by foundry.toml rpc_endpoints / --rpc-url). +HYPEREVM_TESTNET_RPC=https://rpc.hyperliquid-testnet.xyz/evm + +# ECDSA threshold group address (the address ecrecover must match). At the +# 1-of-1 launch this is the single signer's address. +GROUP_ADDRESS=0x... + +# Optional overrides (defaults in Deploy.s.sol): +# GOVERNANCE=0x... # multisig to hand governance to (default: deployer) +# GUARDIAN=0x... # pause authority (default: deployer) +# HYPER_LOCAL_CHAIN_ID=998 # HyperEVM testnet chainId → low 27 bits of internal id +# SUI_LOCAL_ID=0 # Sui's assigned local id +# HYPER_CONFIRMATIONS=12 # ⚠ confirm against Hyperliquid finality docs (spec §4/§9) +# SUI_OUTBOX=0x... # Sui Outbox object id (bytes32), backfillable +# SUI_INBOX=0x... # Sui Inbox object id (bytes32), backfillable diff --git a/sui-bridge-contracts/solidity/.gitignore b/sui-bridge-contracts/solidity/.gitignore new file mode 100644 index 00000000..86dc1f2d --- /dev/null +++ b/sui-bridge-contracts/solidity/.gitignore @@ -0,0 +1,15 @@ +# Foundry +out/ +cache/ +broadcast/ + +# Vendored libraries — restore with the clone in README (not committed) +lib/ + +# Secrets +.env + +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/sui-bridge-contracts/solidity/README.md b/sui-bridge-contracts/solidity/README.md new file mode 100644 index 00000000..50e71681 --- /dev/null +++ b/sui-bridge-contracts/solidity/README.md @@ -0,0 +1,50 @@ +# sui-bridge-contracts/solidity + +HyperEVM (Solidity) side of **Layer 1 — Generic Cross-Chain Messaging** from +[`../../bridge-spec.md`](../../bridge-spec.md). Foundry project. Mirrors the Move +package in [`../sui`](../sui): an Outbox that commits canonical messages and an +Inbox that verifies an aggregated threshold **ECDSA** signature (`ecrecover`) +and dispatches to the destination app. + +``` +cd sui-bridge-contracts/solidity +git clone --depth 1 https://github.com/foundry-rs/forge-std lib/forge-std # if lib/ is absent +forge build && forge test +``` + +## Contracts + +| File | Responsibility | +|------|----------------| +| [`libraries/ChainId.sol`](src/libraries/ChainId.sol) | `(family << 27) \| local` internal chain id — identical to the Move `chain_id` | +| [`libraries/Message.sol`](src/libraries/Message.sol) | `CrossChainMessage` + the big-endian packed canonical encoding + `keccak256` digest | +| [`libraries/Envelope.sol`](src/libraries/Envelope.sol) | `SignatureEnvelope` + `ecrecover` verify adapter (secp256k1, malleability-checked) | +| [`Registry.sol`](src/Registry.sol) | chains + group keys + governance/guardian roles | +| [`Outbox.sol`](src/Outbox.sol) | `send` → nonce + `MessageCommitted`, pausable | +| [`Inbox.sol`](src/Inbox.sol) | `receiveMessage` → verify + exactly-once + `onReceive` dispatch, pausable | +| [`interfaces/IMessageRecipient.sol`](src/interfaces/IMessageRecipient.sol) | destination-app `onReceive` callback | +| [`script/Deploy.s.sol`](script/Deploy.s.sol) | deploy + wire registry/group key on HyperEVM | + +## Parity with the Sui side (the contract that matters) + +The two chains must produce a **byte-identical keccak256 digest** so one +threshold signature verifies on both. `test_known_digest_matches_sui` hashes the +exact message that `sui_bridge::message_tests::known_digest_vector` does and +asserts the same digest — if either encoding drifts, this test breaks. Both +sides sign the raw 32-byte digest (no EIP-191 prefix; Ed25519 over the digest on +Sui, `ecrecover` over the digest here). + +## Notable differences from the Move side + +- **Dispatch is a direct call**, not a hot potato — the EVM has dynamic + dispatch. `consumed[hash]` is set *before* the external `onReceive` + (checks-effects-interactions), so a reentrant replay reverts. +- **Verify is ECDSA/`ecrecover`** (GG20/CGGMP, EVM-destined) vs FROST-Ed25519 on + Sui. The registered group key is the 20-byte group address. + +## Open items (flagged in the spec) + +- `evm_version = "paris"` in `foundry.toml` is conservative pending confirmation + of HyperEVM's opcode support and finality semantics (spec §4/§9). +- `HYPER_CONFIRMATIONS` default (12) is a placeholder — HyperBFT finality must be + confirmed against Hyperliquid docs before fixing it. diff --git a/sui-bridge-contracts/solidity/foundry.toml b/sui-bridge-contracts/solidity/foundry.toml new file mode 100644 index 00000000..761cebe7 --- /dev/null +++ b/sui-bridge-contracts/solidity/foundry.toml @@ -0,0 +1,20 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +test = "test" +script = "script" +solc_version = "0.8.28" +# Conservative target: HyperEVM's opcode support vs Cancun is an open item +# (bridge-spec.md §4/§9). "paris" avoids PUSH0/transient-storage assumptions +# until finality + opcode support are confirmed against Hyperliquid docs. +evm_version = "paris" +optimizer = true +optimizer_runs = 200 + +[rpc_endpoints] +hyperevm_testnet = "${HYPEREVM_TESTNET_RPC}" + +# `forge fmt` defaults are fine; pinned here for determinism. +[fmt] +line_length = 100 diff --git a/sui-bridge-contracts/solidity/remappings.txt b/sui-bridge-contracts/solidity/remappings.txt new file mode 100644 index 00000000..feaba2dd --- /dev/null +++ b/sui-bridge-contracts/solidity/remappings.txt @@ -0,0 +1 @@ +forge-std/=lib/forge-std/src/ diff --git a/sui-bridge-contracts/solidity/script/Deploy.s.sol b/sui-bridge-contracts/solidity/script/Deploy.s.sol new file mode 100644 index 00000000..e36e1900 --- /dev/null +++ b/sui-bridge-contracts/solidity/script/Deploy.s.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {ChainId} from "../src/libraries/ChainId.sol"; +import {Envelope} from "../src/libraries/Envelope.sol"; +import {Registry} from "../src/Registry.sol"; +import {Outbox} from "../src/Outbox.sol"; +import {Inbox} from "../src/Inbox.sol"; + +/// @notice Deploys Layer 1 messaging to HyperEVM and wires the chain registry + +/// the (1-of-1 launch) ECDSA group key. +/// +/// Usage: +/// forge script script/Deploy.s.sol:Deploy \ +/// --rpc-url hyperevm_testnet --broadcast +/// +/// The deployer is the temporary governance during wiring; if GOVERNANCE is set +/// to a different address (e.g. a multisig), governance is transferred at the +/// end. See .env.example for the inputs. +contract Deploy is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(pk); + address governance = vm.envOr("GOVERNANCE", deployer); + address guardian = vm.envOr("GUARDIAN", deployer); + address groupAddress = vm.envAddress("GROUP_ADDRESS"); // ECDSA group address + // 32-byte per-deployment salt; MUST match the Sui contracts + services. + bytes32 deploymentSalt = vm.envBytes32("DEPLOYMENT_SALT"); + + uint32 hyperLocal = uint32(vm.envOr("HYPER_LOCAL_CHAIN_ID", uint256(998))); + uint32 suiLocal = uint32(vm.envOr("SUI_LOCAL_ID", uint256(0))); + uint64 hyperConfirmations = uint64(vm.envOr("HYPER_CONFIRMATIONS", uint256(12))); + + uint32 hyperId = ChainId.encode(ChainId.FAMILY_EVM, hyperLocal); + uint32 suiId = ChainId.encode(ChainId.FAMILY_SUI, suiLocal); + + vm.startBroadcast(pk); + + // Deployer holds governance during wiring so it can register entries. + Registry registry = new Registry(deployer, guardian); + Inbox inbox = new Inbox(registry, hyperId, deploymentSalt); + Outbox outbox = new Outbox(registry, hyperId, deploymentSalt); + + // This chain (HyperEVM): finalityKind 0 = confirmation depth. + registry.registerChain( + hyperId, + abi.encodePacked(hyperLocal), + bytes32(uint256(uint160(address(outbox)))), + bytes32(uint256(uint160(address(inbox)))), + 0, + hyperConfirmations + ); + // Peer chain (Sui): finalityKind 1 = finalized-checkpoint rule. The Sui + // Outbox/Inbox object ids can be backfilled by governance later. + registry.registerChain( + suiId, bytes("sui"), vm.envOr("SUI_OUTBOX", bytes32(0)), vm.envOr("SUI_INBOX", bytes32(0)), 1, 0 + ); + + // 1-of-1 launch: a single aggregated ECDSA key, indistinguishable + // on-chain from a later k-of-n group key. + registry.registerGroupKey(1, Envelope.SCHEME_ECDSA_SECP256K1, abi.encodePacked(groupAddress)); + + if (governance != deployer) { + registry.transferGovernance(governance); + } + + vm.stopBroadcast(); + + console.log("Registry ", address(registry)); + console.log("Inbox ", address(inbox)); + console.log("Outbox ", address(outbox)); + console.log("hyperId ", hyperId); + console.log("suiId ", suiId); + console.log("governance ", governance); + console.log("guardian ", guardian); + console.log("groupAddr ", groupAddress); + console.logBytes32(deploymentSalt); + console.logBytes32(inbox.domainSep()); + } +} diff --git a/sui-bridge-contracts/solidity/script/DeployLocker.s.sol b/sui-bridge-contracts/solidity/script/DeployLocker.s.sol new file mode 100644 index 00000000..aed6e6af --- /dev/null +++ b/sui-bridge-contracts/solidity/script/DeployLocker.s.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import {Outbox} from "../src/Outbox.sol"; +import {Locker} from "../src/Locker.sol"; +import {WrappedToken} from "../src/WrappedToken.sol"; + +/// @notice Deploy one per-asset Locker (bridge-spec.md §3) and wire its peer. +/// +/// Env inputs: +/// PRIVATE_KEY deployer +/// LOCKER_OUTBOX L1 Outbox address (this chain) +/// LOCKER_INBOX L1 Inbox address (this chain) +/// LOCKER_ASSET_ID bytes32 asset id (shared across the route) +/// LOCKER_MODE 0 = Escrow (home), 1 = Mint (foreign) +/// LOCKER_PEER_CHAIN_ID internal id of the sibling chain +/// LOCKER_PEER bytes32 sibling Locker identity (e.g. Sui object id) +/// LOCKER_ADMIN (optional) final admin; defaults to deployer +/// -- Escrow mode -- +/// LOCKER_TOKEN existing ERC-20 to escrow +/// LOCKER_LOCAL_DECIMALS that token's decimals +/// -- Mint mode -- +/// WRAPPED_NAME / WRAPPED_SYMBOL / WRAPPED_DECIMALS for the new WrappedToken +contract DeployLocker is Script { + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + Locker.Mode mode = Locker.Mode(vm.envUint("LOCKER_MODE")); + + vm.startBroadcast(pk); + (address token, uint8 decimals) = _prepareToken(mode, vm.addr(pk)); + // Deployer is admin during wiring so it can set the peer. + Locker locker = new Locker( + Outbox(vm.envAddress("LOCKER_OUTBOX")), + vm.envAddress("LOCKER_INBOX"), + vm.envBytes32("LOCKER_ASSET_ID"), + token, + mode, + decimals, + vm.addr(pk) + ); + if (mode == Locker.Mode.Mint) { + WrappedToken(token).transferOwnership(address(locker)); + } + locker.setPeer(uint32(vm.envUint("LOCKER_PEER_CHAIN_ID")), vm.envBytes32("LOCKER_PEER")); + _maybeTransferAdmin(locker, vm.addr(pk)); + vm.stopBroadcast(); + + console.log("Locker ", address(locker)); + console.log("token ", token); + } + + /// New WrappedToken (Mint) or the configured existing ERC-20 (Escrow). + function _prepareToken(Locker.Mode mode, address deployer) + internal + returns (address token, uint8 decimals) + { + if (mode == Locker.Mode.Mint) { + WrappedToken w = new WrappedToken( + vm.envString("WRAPPED_NAME"), + vm.envString("WRAPPED_SYMBOL"), + uint8(vm.envUint("WRAPPED_DECIMALS")), + deployer + ); + return (address(w), w.decimals()); + } + return (vm.envAddress("LOCKER_TOKEN"), uint8(vm.envUint("LOCKER_LOCAL_DECIMALS"))); + } + + function _maybeTransferAdmin(Locker locker, address deployer) internal { + address admin = vm.envOr("LOCKER_ADMIN", deployer); + if (admin != deployer) { + locker.transferAdmin(admin); + } + } +} diff --git a/sui-bridge-contracts/solidity/src/Inbox.sol b/sui-bridge-contracts/solidity/src/Inbox.sol new file mode 100644 index 00000000..63d2640c --- /dev/null +++ b/sui-bridge-contracts/solidity/src/Inbox.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Envelope} from "./libraries/Envelope.sol"; +import {Message} from "./libraries/Message.sol"; +import {Registry} from "./Registry.sol"; +import {IMessageRecipient} from "./interfaces/IMessageRecipient.sol"; + +/// @title Inbox +/// @notice One per chain. Verifies an aggregated threshold ECDSA signature +/// against the registered group key, enforces exactly-once delivery, +/// and dispatches the payload to the destination app (bridge-spec.md +/// §2.5). Unlike Move, the EVM has dynamic dispatch, so delivery is a +/// direct `onReceive` call rather than a hot potato. +/// +/// No cross-message ordering is enforced (§2.6): the `consumed` hash-set +/// is the sole exactly-once guard, marked before the external call +/// (checks-effects-interactions) so a reentrant replay reverts. +contract Inbox { + Registry public immutable registry; + /// Internal id of THIS chain; every accepted message must target it. + uint32 public immutable dstChainId; + /// Digest domain separator (spec §2.2), derived from the deployment salt. + bytes32 public immutable domainSep; + + mapping(bytes32 => bool) public consumed; + mapping(uint32 => uint64) public highestNonce; // observability only + bool public paused; + + event MessageDelivered(bytes32 indexed messageHash, uint32 srcChainId, uint64 nonce); + event PausedSet(bool paused); + + error InboxPaused(); + error WrongDstChain(uint32 expected, uint32 got); + error AlreadyConsumed(bytes32 messageHash); + error SchemeKeyMismatch(uint8 registered, uint8 envelope); + error UnsupportedScheme(uint8 schemeTag); + error BadGroupKeyLength(uint256 length); + error NotGuardian(); + + modifier onlyGuardian() { + if (msg.sender != registry.guardian()) revert NotGuardian(); + _; + } + + constructor(Registry registry_, uint32 dstChainId_, bytes32 deploymentSalt) { + registry = registry_; + dstChainId = dstChainId_; + domainSep = Message.deriveDomainSep(deploymentSalt); + } + + /// @notice Verify and deliver a message. Named `receiveMessage` because + /// `receive` is reserved by Solidity (the ether-receive function). + /// The relayer is untrusted: every check is self-contained here. + function receiveMessage( + Message.CrossChainMessage calldata message, + Envelope.SignatureEnvelope calldata envelope + ) external { + if (paused) revert InboxPaused(); + if (message.dstChainId != dstChainId) revert WrongDstChain(dstChainId, message.dstChainId); + + bytes32 messageHash = Message.hash(message, domainSep); + if (consumed[messageHash]) revert AlreadyConsumed(messageHash); + + (uint8 registeredScheme, bytes memory key) = registry.groupKey(envelope.groupPubkeyId); + if (registeredScheme != envelope.schemeTag) { + revert SchemeKeyMismatch(registeredScheme, envelope.schemeTag); + } + if (envelope.schemeTag != Envelope.SCHEME_ECDSA_SECP256K1) { + revert UnsupportedScheme(envelope.schemeTag); + } + Envelope.verifyEcdsa(messageHash, envelope.signature, _keyToAddress(key)); + + // Effects before interaction (replay-safe under reentrancy). + consumed[messageHash] = true; + if (message.nonce > highestNonce[message.srcChainId]) { + highestNonce[message.srcChainId] = message.nonce; + } + emit MessageDelivered(messageHash, message.srcChainId, message.nonce); + + IMessageRecipient(Message.bytes32ToAddress(message.dstApp)).onReceive( + message.srcChainId, message.srcApp, message.payload + ); + } + + /// @notice Global inbound circuit breaker (§2.7). Guardian-gated. + function setPaused(bool paused_) external onlyGuardian { + paused = paused_; + emit PausedSet(paused_); + } + + /// @dev Decode a 20-byte registered ECDSA group key into an address. + function _keyToAddress(bytes memory key) private pure returns (address addr) { + if (key.length != 20) revert BadGroupKeyLength(key.length); + assembly { + // First 32 bytes of `key` data hold the 20-byte address left-aligned; + // shift right 96 bits to right-align it. + addr := shr(96, mload(add(key, 0x20))) + } + } +} + diff --git a/sui-bridge-contracts/solidity/src/Locker.sol b/sui-bridge-contracts/solidity/src/Locker.sol new file mode 100644 index 00000000..f917cabc --- /dev/null +++ b/sui-bridge-contracts/solidity/src/Locker.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Message} from "./libraries/Message.sol"; +import {TransferPayload} from "./libraries/TransferPayload.sol"; +import {Outbox} from "./Outbox.sol"; +import {IMessageRecipient} from "./interfaces/IMessageRecipient.sol"; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +interface IWrappedToken { + function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; +} + +/// @title Locker +/// @notice Layer 2 lock-and-mint app, one deployment per asset (bridge-spec.md +/// §3), mirroring the Move `locker::locker`. +/// - Home chain: `Escrow` — holds the native ERC-20 in escrow. +/// - Foreign chain: `Mint` — holds mint/burn authority over a WrappedToken. +/// Inbound delivery arrives via the Inbox `onReceive` callback (EVM has +/// dynamic dispatch, so no hot potato). Amounts cross the wire at +/// `WIRE_DECIMALS`; the Locker scales to/from local decimals with NTT +/// dust rejection. Over-limit inbound transfers queue and are claimable +/// after the window — they never revert (§3.5). +contract Locker is IMessageRecipient { + enum Mode { + Escrow, // home + Mint // foreign + } + + // --- immutable config --- + Outbox public immutable outbox; + address public immutable inbox; + bytes32 public immutable assetId; + address public immutable token; // ERC-20 (Escrow) or WrappedToken (Mint) + Mode public immutable mode; + uint8 public immutable localDecimals; + + // --- governance --- + address public admin; + + // --- routing / controls --- + mapping(uint32 => bytes32) public peers; // chainId => sibling Locker identity + bool public paused; + + // --- inbound rate limit (wire units); cap == 0 disables --- + uint64 public rateLimitWindow; + uint64 public rateLimitCap; + uint64 public windowStart; + uint64 public windowUsed; + + // --- overflow queue --- + struct Queued { + address recipient; + uint64 wireAmount; + uint64 unlockAt; + bool claimed; + } + + mapping(uint256 => Queued) public queued; + uint256 public nextQueueId; + + // --- events --- + event BridgedOut(uint32 indexed dstChainId, uint64 nonce, uint64 wireAmount, bytes32 recipient); + event BridgedIn(uint32 indexed srcChainId, uint64 wireAmount, address recipient); + event TransferQueued(uint256 indexed id, address recipient, uint64 wireAmount, uint64 unlockAt); + event TransferClaimed(uint256 indexed id, address recipient, uint64 wireAmount); + event PeerSet(uint32 indexed chainId, bytes32 peer); + event PausedSet(bool paused); + event RateLimitSet(uint64 window, uint64 cap); + event AdminTransferred(address indexed from, address indexed to); + + // --- errors --- + error NotAdmin(); + error NotInbox(); + error Paused(); + error WrongMode(); + error ZeroAmount(); + error UnknownPeer(uint32 chainId); + error PeerMismatch(); + error AssetMismatch(); + error AmountHasDust(); + error AmountOverflow(); + error TransferFailed(); + error BadQueueEntry(); + error StillLocked(); + + modifier onlyAdmin() { + if (msg.sender != admin) revert NotAdmin(); + _; + } + + constructor( + Outbox outbox_, + address inbox_, + bytes32 assetId_, + address token_, + Mode mode_, + uint8 localDecimals_, + address admin_ + ) { + outbox = outbox_; + inbox = inbox_; + assetId = assetId_; + token = token_; + mode = mode_; + localDecimals = localDecimals_; + admin = admin_; + } + + // --- admin --- + + function setPeer(uint32 chainId, bytes32 peer) external onlyAdmin { + peers[chainId] = peer; + emit PeerSet(chainId, peer); + } + + /// @notice Hand admin authority to governance (e.g. a multisig) after wiring. + function transferAdmin(address newAdmin) external onlyAdmin { + emit AdminTransferred(admin, newAdmin); + admin = newAdmin; + } + + function setPaused(bool paused_) external onlyAdmin { + paused = paused_; + emit PausedSet(paused_); + } + + function setRateLimit(uint64 window, uint64 cap) external onlyAdmin { + rateLimitWindow = window; + rateLimitCap = cap; + windowUsed = 0; + emit RateLimitSet(window, cap); + } + + // --- outbound --- + + /// @notice Home-chain escrow-and-send. + function lock(uint256 amount, uint32 dstChainId, bytes32 recipient) external { + if (mode != Mode.Escrow) revert WrongMode(); + _bridgeOut(amount, dstChainId, recipient); + } + + /// @notice Foreign-chain burn-and-send. + function burn(uint256 amount, uint32 dstChainId, bytes32 recipient) external { + if (mode != Mode.Mint) revert WrongMode(); + _bridgeOut(amount, dstChainId, recipient); + } + + function _bridgeOut(uint256 amount, uint32 dstChainId, bytes32 recipient) internal { + if (paused) revert Paused(); + if (amount == 0) revert ZeroAmount(); + bytes32 peer = peers[dstChainId]; + if (peer == bytes32(0)) revert UnknownPeer(dstChainId); + + uint64 wireAmount = _toWire(amount); + + if (mode == Mode.Escrow) { + _safeTransferFrom(msg.sender, address(this), amount); + } else { + IWrappedToken(token).burn(msg.sender, amount); + } + + bytes memory payload = + TransferPayload.encode(TransferPayload.Data(assetId, wireAmount, recipient)); + (uint64 nonce,) = outbox.send(dstChainId, peer, payload); + emit BridgedOut(dstChainId, nonce, wireAmount, recipient); + } + + // --- inbound (Inbox callback) --- + + function onReceive(uint32 srcChainId, bytes32 srcApp, bytes calldata payload) external { + if (msg.sender != inbox) revert NotInbox(); + if (paused) revert Paused(); + bytes32 peer = peers[srcChainId]; + if (peer == bytes32(0) || srcApp != peer) revert PeerMismatch(); + + TransferPayload.Data memory tp = TransferPayload.decode(payload); + if (tp.assetId != assetId) revert AssetMismatch(); + if (tp.amount == 0) revert ZeroAmount(); + + address recipient = address(uint160(uint256(tp.recipient))); + + if (_consumeRateLimit(tp.amount)) { + _deliver(recipient, tp.amount); + emit BridgedIn(srcChainId, tp.amount, recipient); + } else { + // Over the window cap: queue instead of reverting (§3.5). The message + // is still consumed at the Inbox; only the payout is delayed. + uint64 unlockAt = windowStart + rateLimitWindow; + uint256 id = nextQueueId++; + queued[id] = Queued(recipient, tp.amount, unlockAt, false); + emit TransferQueued(id, recipient, tp.amount, unlockAt); + } + } + + /// @notice Permissionless release of a queued transfer once its window passed. + /// The delay itself was the rate-limit control, so a claim does not + /// consume budget. + function claim(uint256 id) external { + if (paused) revert Paused(); + Queued storage q = queued[id]; + if (q.recipient == address(0) || q.claimed) revert BadQueueEntry(); + if (block.timestamp < q.unlockAt) revert StillLocked(); + q.claimed = true; + _deliver(q.recipient, q.wireAmount); + emit TransferClaimed(id, q.recipient, q.wireAmount); + } + + function _deliver(address recipient, uint64 wireAmount) internal { + uint256 local = _fromWire(wireAmount); + if (mode == Mode.Escrow) { + _safeTransfer(recipient, local); + } else { + IWrappedToken(token).mint(recipient, local); + } + } + + // --- rate limit --- + + /// @dev Returns true and reserves budget if within the window cap; false if + /// over (caller queues). cap == 0 disables the limit (always true). + function _consumeRateLimit(uint64 wireAmount) internal returns (bool) { + if (rateLimitCap == 0) return true; + if (block.timestamp >= windowStart + rateLimitWindow) { + windowStart = uint64(block.timestamp); + windowUsed = 0; + } + if (windowUsed + wireAmount <= rateLimitCap) { + windowUsed += wireAmount; + return true; + } + return false; + } + + // --- decimals scaling (NTT trimmed-amount; mirrors Move to_wire/from_wire) --- + + function _toWire(uint256 localAmount) internal view returns (uint64) { + uint8 wire = TransferPayload.WIRE_DECIMALS; + uint256 w; + if (localDecimals >= wire) { + uint256 factor = 10 ** (localDecimals - wire); + if (localAmount % factor != 0) revert AmountHasDust(); + w = localAmount / factor; + } else { + w = localAmount * (10 ** (wire - localDecimals)); + } + if (w > type(uint64).max) revert AmountOverflow(); + return uint64(w); + } + + function _fromWire(uint64 wireAmount) internal view returns (uint256) { + uint8 wire = TransferPayload.WIRE_DECIMALS; + if (localDecimals >= wire) { + return uint256(wireAmount) * (10 ** (localDecimals - wire)); + } else { + uint256 factor = 10 ** (wire - localDecimals); + if (wireAmount % factor != 0) revert AmountHasDust(); + return uint256(wireAmount) / factor; + } + } + + // --- ERC-20 safe transfer (tolerates non-bool-returning tokens) --- + + function _safeTransfer(address to, uint256 amount) internal { + (bool ok, bytes memory data) = + token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount)); + if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } + + function _safeTransferFrom(address from, address to, uint256 amount) internal { + (bool ok, bytes memory data) = + token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, amount)); + if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } + + // --- views --- + + /// @notice Escrowed balance held by this Locker (home chain). + function escrowed() external view returns (uint256) { + return IERC20(token).balanceOf(address(this)); + } +} diff --git a/sui-bridge-contracts/solidity/src/Outbox.sol b/sui-bridge-contracts/solidity/src/Outbox.sol new file mode 100644 index 00000000..29e6155c --- /dev/null +++ b/sui-bridge-contracts/solidity/src/Outbox.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Message} from "./libraries/Message.sol"; +import {Registry} from "./Registry.sol"; + +/// @title Outbox +/// @notice One per chain. Apps call `send` to emit a cross-chain message; it +/// assigns a per-destination nonce, computes the canonical keccak256 +/// digest, and emits `MessageCommitted` for the signer group to observe +/// (bridge-spec.md §2.4). `srcApp` is the caller's left-padded address. +contract Outbox { + Registry public immutable registry; + /// Internal id of THIS chain (the source for everything it emits). + uint32 public immutable srcChainId; + /// Digest domain separator (spec §2.2), derived from the deployment salt. + bytes32 public immutable domainSep; + + /// nextNonce[dstChainId] — monotonic per (src, dst) lane (§2.6). + mapping(uint32 => uint64) public nextNonce; + bool public paused; + + event MessageCommitted( + bytes32 indexed messageHash, + uint32 srcChainId, + uint32 dstChainId, + uint64 nonce, + bytes32 srcApp, + bytes32 dstApp, + bytes payload + ); + event PausedSet(bool paused); + + error OutboxPaused(); + error NotGuardian(); + + modifier onlyGuardian() { + if (msg.sender != registry.guardian()) revert NotGuardian(); + _; + } + + constructor(Registry registry_, uint32 srcChainId_, bytes32 deploymentSalt) { + registry = registry_; + srcChainId = srcChainId_; + domainSep = Message.deriveDomainSep(deploymentSalt); + } + + /// @notice Emit a message to `dstApp` on `dstChainId`. Returns the assigned + /// nonce and canonical message hash. + function send(uint32 dstChainId, bytes32 dstApp, bytes calldata payload) + external + returns (uint64 nonce, bytes32 messageHash) + { + if (paused) revert OutboxPaused(); + + nonce = nextNonce[dstChainId]; + bytes32 srcApp = Message.addressToBytes32(msg.sender); + Message.CrossChainMessage memory m = Message.CrossChainMessage({ + version: Message.VERSION, + srcChainId: srcChainId, + dstChainId: dstChainId, + nonce: nonce, + srcApp: srcApp, + dstApp: dstApp, + payload: payload + }); + messageHash = Message.hash(m, domainSep); + + nextNonce[dstChainId] = nonce + 1; + + emit MessageCommitted(messageHash, srcChainId, dstChainId, nonce, srcApp, dstApp, payload); + } + + /// @notice Global outbound circuit breaker (§2.7). Guardian-gated. + function setPaused(bool paused_) external onlyGuardian { + paused = paused_; + emit PausedSet(paused_); + } +} diff --git a/sui-bridge-contracts/solidity/src/Registry.sol b/sui-bridge-contracts/solidity/src/Registry.sol new file mode 100644 index 00000000..1b7cf42d --- /dev/null +++ b/sui-bridge-contracts/solidity/src/Registry.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ChainId} from "./libraries/ChainId.sol"; + +/// @title Registry +/// @notice Chain registry + group-key registry + role holders, mirroring the +/// Move `sui_bridge::registry` (bridge-spec.md §7). Two roles: +/// - governance: edit chains, register group keys, set threshold. +/// - guardian: pause/unpause Outbox + Inbox (those read `guardian()`). +/// +/// The family is derived from the internal id's top bits (see ChainId), +/// never stored separately, so the two can't disagree. +contract Registry { + struct ChainEntry { + bytes nativeIdentifier; // authoritative native id (full EVM chainId, Sui id, …) + bytes32 outboxAddr; + bytes32 inboxAddr; + uint8 finalityKind; // 0 = EVM confirmation depth, 1 = Sui finalized-checkpoint rule + uint64 finalityValue; + bool exists; + } + + struct GroupKey { + uint8 schemeTag; + bytes key; // ECDSA: 20-byte group address; Ed25519: 32-byte pubkey + bool exists; + } + + address public governance; + address public guardian; + + mapping(uint32 => ChainEntry) internal chains; + mapping(uint32 => GroupKey) internal groupKeys; + + // Signer threshold (k-of-n) — governance metadata. Not enforced on-chain: a + // single aggregated signature verifies against the group key regardless. + uint16 public thresholdK = 1; + uint16 public thresholdN = 1; + + event ChainRegistered(uint32 indexed internalId, uint8 family); + event GroupKeyRegistered(uint32 indexed groupPubkeyId, uint8 schemeTag); + event ThresholdSet(uint16 k, uint16 n); + event GuardianSet(address indexed guardian); + event GovernanceTransferred(address indexed from, address indexed to); + + error NotGovernance(); + error ChainAlreadyRegistered(uint32 internalId); + error ChainNotRegistered(uint32 internalId); + error GroupKeyAlreadyRegistered(uint32 groupPubkeyId); + error GroupKeyNotRegistered(uint32 groupPubkeyId); + error InvalidThreshold(uint16 k, uint16 n); + error ZeroAddress(); + + modifier onlyGovernance() { + if (msg.sender != governance) revert NotGovernance(); + _; + } + + constructor(address governance_, address guardian_) { + if (governance_ == address(0) || guardian_ == address(0)) revert ZeroAddress(); + governance = governance_; + guardian = guardian_; + } + + // --- chain registry --- + + function registerChain( + uint32 internalId, + bytes calldata nativeIdentifier, + bytes32 outboxAddr, + bytes32 inboxAddr, + uint8 finalityKind, + uint64 finalityValue + ) external onlyGovernance { + if (chains[internalId].exists) revert ChainAlreadyRegistered(internalId); + uint8 fam = ChainId.family(internalId); + if (!ChainId.isValidFamily(fam)) revert ChainId.UnknownFamily(fam); + chains[internalId] = ChainEntry({ + nativeIdentifier: nativeIdentifier, + outboxAddr: outboxAddr, + inboxAddr: inboxAddr, + finalityKind: finalityKind, + finalityValue: finalityValue, + exists: true + }); + emit ChainRegistered(internalId, fam); + } + + function isRegistered(uint32 internalId) external view returns (bool) { + return chains[internalId].exists; + } + + function family(uint32 internalId) external view returns (uint8) { + if (!chains[internalId].exists) revert ChainNotRegistered(internalId); + return ChainId.family(internalId); + } + + function finality(uint32 internalId) external view returns (uint8 kind, uint64 value) { + ChainEntry storage e = chains[internalId]; + if (!e.exists) revert ChainNotRegistered(internalId); + return (e.finalityKind, e.finalityValue); + } + + // --- group-key registry --- + + function registerGroupKey(uint32 groupPubkeyId, uint8 schemeTag, bytes calldata key) + external + onlyGovernance + { + if (groupKeys[groupPubkeyId].exists) revert GroupKeyAlreadyRegistered(groupPubkeyId); + groupKeys[groupPubkeyId] = GroupKey({schemeTag: schemeTag, key: key, exists: true}); + emit GroupKeyRegistered(groupPubkeyId, schemeTag); + } + + function hasGroupKey(uint32 groupPubkeyId) external view returns (bool) { + return groupKeys[groupPubkeyId].exists; + } + + function groupKey(uint32 groupPubkeyId) + external + view + returns (uint8 schemeTag, bytes memory key) + { + GroupKey storage gk = groupKeys[groupPubkeyId]; + if (!gk.exists) revert GroupKeyNotRegistered(groupPubkeyId); + return (gk.schemeTag, gk.key); + } + + function setSignerThreshold(uint16 k, uint16 n) external onlyGovernance { + if (k == 0 || k > n) revert InvalidThreshold(k, n); + thresholdK = k; + thresholdN = n; + emit ThresholdSet(k, n); + } + + // --- roles --- + + function setGuardian(address guardian_) external onlyGovernance { + if (guardian_ == address(0)) revert ZeroAddress(); + guardian = guardian_; + emit GuardianSet(guardian_); + } + + function transferGovernance(address governance_) external onlyGovernance { + if (governance_ == address(0)) revert ZeroAddress(); + emit GovernanceTransferred(governance, governance_); + governance = governance_; + } +} diff --git a/sui-bridge-contracts/solidity/src/WrappedToken.sol b/sui-bridge-contracts/solidity/src/WrappedToken.sol new file mode 100644 index 00000000..682a7957 --- /dev/null +++ b/sui-bridge-contracts/solidity/src/WrappedToken.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title WrappedToken +/// @notice Minimal ERC-20 whose mint/burn authority is held by a single owner — +/// the foreign-chain Locker (bridge-spec.md §3.1). Deploy with the +/// deployer as owner, then `transferOwnership` to the Locker so only the +/// Locker can mint on delivery and burn on bridge-out. +contract WrappedToken { + string public name; + string public symbol; + uint8 public immutable decimals; + + address public owner; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event OwnershipTransferred(address indexed from, address indexed to); + + error NotOwner(); + error InsufficientBalance(); + error InsufficientAllowance(); + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(string memory name_, string memory symbol_, uint8 decimals_, address owner_) { + name = name_; + symbol = symbol_; + decimals = decimals_; + owner = owner_; + emit OwnershipTransferred(address(0), owner_); + } + + /// @notice Hand mint/burn authority to the Locker after it is deployed. + function transferOwnership(address newOwner) external onlyOwner { + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + function mint(address to, uint256 amount) external onlyOwner { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + /// @notice Burn from `from`'s balance. The Locker only burns the caller's own + /// balance (it passes `msg.sender` of `bridgeOut`). + function burn(address from, uint256 amount) external onlyOwner { + uint256 bal = balanceOf[from]; + if (bal < amount) revert InsufficientBalance(); + balanceOf[from] = bal - amount; + totalSupply -= amount; + emit Transfer(from, address(0), amount); + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + if (allowed < amount) revert InsufficientAllowance(); + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function _transfer(address from, address to, uint256 amount) internal { + uint256 bal = balanceOf[from]; + if (bal < amount) revert InsufficientBalance(); + balanceOf[from] = bal - amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/sui-bridge-contracts/solidity/src/interfaces/IMessageRecipient.sol b/sui-bridge-contracts/solidity/src/interfaces/IMessageRecipient.sol new file mode 100644 index 00000000..c944eb68 --- /dev/null +++ b/sui-bridge-contracts/solidity/src/interfaces/IMessageRecipient.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IMessageRecipient +/// @notice Destination-app callback the Inbox dispatches to (bridge-spec.md +/// §3.4 `onReceive`). The app MUST check `msg.sender == inbox` and that +/// `srcApp` is its registered peer before acting on `payload`. +interface IMessageRecipient { + function onReceive(uint32 srcChainId, bytes32 srcApp, bytes calldata payload) external; +} diff --git a/sui-bridge-contracts/solidity/src/libraries/ChainId.sol b/sui-bridge-contracts/solidity/src/libraries/ChainId.sol new file mode 100644 index 00000000..de4eeb6d --- /dev/null +++ b/sui-bridge-contracts/solidity/src/libraries/ChainId.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ChainId +/// @notice Self-describing internal chain id, identical to the Move +/// `sui_bridge::chain_id` module so both sides agree on the values in a +/// signed message. +/// +/// internal_id = (family << 27) | local +/// - top 5 bits : family (1=Sui, 2=EVM, 3=Solana, 4=Aptos) +/// - low 27 bits : per-family local id +/// +/// The 27-bit local field caps at 134,217,727. For EVM it SHOULD be the +/// native chainId when it fits (HyperEVM testnet = 998 does); otherwise +/// an assigned index, with the registry's `nativeIdentifier` holding the +/// authoritative value. +library ChainId { + uint8 internal constant FAMILY_SUI = 1; + uint8 internal constant FAMILY_EVM = 2; + uint8 internal constant FAMILY_SOLANA = 3; + uint8 internal constant FAMILY_APTOS = 4; + + uint8 internal constant FAMILY_SHIFT = 27; + uint32 internal constant LOCAL_MASK = 0x07FFFFFF; // low 27 bits + uint32 internal constant FAMILY_MASK = 0x1F; // 5 bits + + error UnknownFamily(uint8 family); + error LocalTooLarge(uint32 local); + + function isValidFamily(uint8 fam) internal pure returns (bool) { + return fam >= FAMILY_SUI && fam <= FAMILY_APTOS; + } + + function encode(uint8 fam, uint32 loc) internal pure returns (uint32) { + if (!isValidFamily(fam)) revert UnknownFamily(fam); + if (loc > LOCAL_MASK) revert LocalTooLarge(loc); + return (uint32(fam) << FAMILY_SHIFT) | loc; + } + + function family(uint32 internalId) internal pure returns (uint8) { + return uint8((internalId >> FAMILY_SHIFT) & FAMILY_MASK); + } + + function local(uint32 internalId) internal pure returns (uint32) { + return internalId & LOCAL_MASK; + } +} diff --git a/sui-bridge-contracts/solidity/src/libraries/Envelope.sol b/sui-bridge-contracts/solidity/src/libraries/Envelope.sol new file mode 100644 index 00000000..48d195d9 --- /dev/null +++ b/sui-bridge-contracts/solidity/src/libraries/Envelope.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title Envelope +/// @notice Signature envelope + the EVM verify adapter. Mirrors the +/// `(scheme_tag, group_pubkey_id, signature)` shape of +/// `sui_bridge::envelope` (bridge-spec.md §2.3). Messages destined for +/// EVM are signed with GG20/CGGMP threshold ECDSA (secp256k1) and +/// verified here via `ecrecover` against the registered group address. +library Envelope { + uint8 internal constant SCHEME_ED25519 = 0; + uint8 internal constant SCHEME_ECDSA_SECP256K1 = 1; + + // secp256k1 group order / 2 — reject the high-s malleable variant. + uint256 internal constant SECP256K1_HALF_N = + 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0; + + struct SignatureEnvelope { + uint8 schemeTag; + uint32 groupPubkeyId; + bytes signature; // ECDSA: 65 bytes, r || s || v + } + + error BadSignatureLength(uint256 length); + error MalleableSignature(); + error InvalidSignature(); + + /// @notice Verify a 65-byte ECDSA signature over `digest` recovers to + /// `groupAddr`. Reverts otherwise. + function verifyEcdsa(bytes32 digest, bytes memory signature, address groupAddr) internal pure { + if (signature.length != 65) revert BadSignatureLength(signature.length); + + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + if (uint256(s) > SECP256K1_HALF_N) revert MalleableSignature(); + if (v != 27 && v != 28) revert InvalidSignature(); + + address recovered = ecrecover(digest, v, r, s); + if (recovered == address(0) || recovered != groupAddr) revert InvalidSignature(); + } +} diff --git a/sui-bridge-contracts/solidity/src/libraries/Message.sol b/sui-bridge-contracts/solidity/src/libraries/Message.sol new file mode 100644 index 00000000..d005f614 --- /dev/null +++ b/sui-bridge-contracts/solidity/src/libraries/Message.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title Message +/// @notice Canonical cross-chain message + keccak256 digest. The encoding MUST +/// be byte-identical to `sui_bridge::message` (Move) so one threshold +/// signature verifies on both chains (bridge-spec.md §2.2). +/// +/// Fixed big-endian packed layout — `abi.encodePacked` reproduces the +/// exact bytes the Move `encode` emits: +/// +/// version (u8) | srcChainId (u32) | dstChainId (u32) | nonce (u64) +/// | srcApp (bytes32) | dstApp (bytes32) | payloadLen (u32) | payload +/// +/// message_hash = keccak256(DOMAIN_SEP || encode(message)), where +/// DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deploymentSalt) binds every +/// signed message to one logical deployment (spec §2.2). Signers sign +/// over this 32-byte digest directly (no EIP-191 prefix), matching the +/// Ed25519 path on Sui. +library Message { + uint8 internal constant VERSION = 1; + + /// @notice Domain-separation tag hashed with the per-deployment salt. + bytes13 internal constant DOMAIN_TAG = "XCHAIN_MSG_V1"; + + error BadVersion(uint8 version); + + struct CrossChainMessage { + uint8 version; + uint32 srcChainId; + uint32 dstChainId; + uint64 nonce; + bytes32 srcApp; + bytes32 dstApp; + bytes payload; + } + + function encode(CrossChainMessage memory m) internal pure returns (bytes memory) { + if (m.version != VERSION) revert BadVersion(m.version); + return abi.encodePacked( + m.version, + m.srcChainId, + m.dstChainId, + m.nonce, + m.srcApp, + m.dstApp, + uint32(m.payload.length), + m.payload + ); + } + + /// @notice DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deploymentSalt). + /// Derived once at contract construction so the stored separator is + /// auditable on-chain. + function deriveDomainSep(bytes32 deploymentSalt) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(DOMAIN_TAG, deploymentSalt)); + } + + /// @notice keccak256(domainSep || encode(message)) — the digest signers sign. + function hash(CrossChainMessage memory m, bytes32 domainSep) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(domainSep, encode(m))); + } + + /// @notice Left-pad an EVM address into a bytes32 app identity (spec §2.2). + function addressToBytes32(address a) internal pure returns (bytes32) { + return bytes32(uint256(uint160(a))); + } + + /// @notice Recover an EVM address from a bytes32 app identity. + function bytes32ToAddress(bytes32 b) internal pure returns (address) { + return address(uint160(uint256(b))); + } +} diff --git a/sui-bridge-contracts/solidity/src/libraries/TransferPayload.sol b/sui-bridge-contracts/solidity/src/libraries/TransferPayload.sol new file mode 100644 index 00000000..07719ba6 --- /dev/null +++ b/sui-bridge-contracts/solidity/src/libraries/TransferPayload.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title TransferPayload +/// @notice Layer 2 transfer payload carried in `CrossChainMessage.payload` +/// (bridge-spec.md §3.2/§3.3). Byte-identical to the Move +/// `locker::transfer_payload` and Rust `bridge_types::transfer` codecs. +/// +/// Fixed big-endian packed layout (72 bytes): +/// assetId bytes32 32 +/// amount uint64 big-endian 8 (wire amount, fixed WIRE_DECIMALS) +/// recipient bytes32 32 +library TransferPayload { + /// Shared wire precision; the Locker scales to/from local decimals. + uint8 internal constant WIRE_DECIMALS = 8; + uint256 internal constant ENCODED_LEN = 72; + + struct Data { + bytes32 assetId; + uint64 amount; + bytes32 recipient; + } + + error BadPayloadLength(uint256 length); + + function encode(Data memory d) internal pure returns (bytes memory) { + return abi.encodePacked(d.assetId, d.amount, d.recipient); + } + + function decode(bytes memory b) internal pure returns (Data memory d) { + if (b.length != ENCODED_LEN) revert BadPayloadLength(b.length); + bytes32 assetId; + uint64 amount; + bytes32 recipient; + assembly { + assetId := mload(add(b, 0x20)) // bytes [0,32) + amount := shr(192, mload(add(b, 0x40))) // top 8 bytes of [32,64) + recipient := mload(add(b, 0x48)) // bytes [40,72) + } + d = Data(assetId, amount, recipient); + } +} diff --git a/sui-bridge-contracts/solidity/test/ChainId.t.sol b/sui-bridge-contracts/solidity/test/ChainId.t.sol new file mode 100644 index 00000000..c8e83c59 --- /dev/null +++ b/sui-bridge-contracts/solidity/test/ChainId.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ChainId} from "../src/libraries/ChainId.sol"; + +contract ChainIdTest is Test { + function test_encode_round_trips() public pure { + uint32 hyper = ChainId.encode(ChainId.FAMILY_EVM, 998); + uint32 sui = ChainId.encode(ChainId.FAMILY_SUI, 0); + + // Matches the Move/Sui constants exactly. + assertEq(hyper, 268436454); + assertEq(sui, 134217728); + + assertEq(ChainId.family(hyper), ChainId.FAMILY_EVM); + assertEq(uint256(ChainId.local(hyper)), 998); + assertEq(ChainId.family(sui), ChainId.FAMILY_SUI); + assertEq(uint256(ChainId.local(sui)), 0); + } + + function test_isValidFamily() public pure { + assertTrue(ChainId.isValidFamily(ChainId.FAMILY_SUI)); + assertTrue(ChainId.isValidFamily(ChainId.FAMILY_APTOS)); + assertFalse(ChainId.isValidFamily(0)); + assertFalse(ChainId.isValidFamily(5)); + } + + function test_encode_rejects_oversized_local() public { + vm.expectRevert( + abi.encodeWithSelector(ChainId.LocalTooLarge.selector, ChainId.LOCAL_MASK + 1) + ); + this.encodeExt(ChainId.FAMILY_EVM, ChainId.LOCAL_MASK + 1); + } + + function test_encode_rejects_bad_family() public { + vm.expectRevert(abi.encodeWithSelector(ChainId.UnknownFamily.selector, uint8(9))); + this.encodeExt(9, 1); + } + + /// External wrapper so `vm.expectRevert` sees a call boundary. + function encodeExt(uint8 family, uint32 local) external pure returns (uint32) { + return ChainId.encode(family, local); + } +} diff --git a/sui-bridge-contracts/solidity/test/Locker.t.sol b/sui-bridge-contracts/solidity/test/Locker.t.sol new file mode 100644 index 00000000..ff413e6b --- /dev/null +++ b/sui-bridge-contracts/solidity/test/Locker.t.sol @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ChainId} from "../src/libraries/ChainId.sol"; +import {Envelope} from "../src/libraries/Envelope.sol"; +import {Message} from "../src/libraries/Message.sol"; +import {TransferPayload} from "../src/libraries/TransferPayload.sol"; +import {Registry} from "../src/Registry.sol"; +import {Outbox} from "../src/Outbox.sol"; +import {Inbox} from "../src/Inbox.sol"; +import {Locker} from "../src/Locker.sol"; +import {WrappedToken} from "../src/WrappedToken.sol"; + +contract LockerTest is Test { + Registry registry; + Outbox outbox; + Inbox inbox; + + // Home (18-dec ERC-20, escrow) and foreign (6-dec wrapped, mint) lockers, + // both on HYPER_ID; the sibling chain is SUI_ID for routing. + WrappedToken homeToken; + Locker homeLocker; + WrappedToken wrapped; + Locker foreignLocker; + + address admin = address(this); + address guardian = makeAddr("guardian"); + address user = makeAddr("user"); + address recipient = makeAddr("recipient"); + + bytes32 constant ASSET = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + bytes32 constant SALT = 0x0101010101010101010101010101010101010101010101010101010101010101; + uint256 constant GROUP_PK = 0xA11CE; + + uint32 immutable SUI_ID = ChainId.encode(ChainId.FAMILY_SUI, 0); + uint32 immutable HYPER_ID = ChainId.encode(ChainId.FAMILY_EVM, 998); + + function setUp() public { + registry = new Registry(admin, guardian); + outbox = new Outbox(registry, HYPER_ID, SALT); + inbox = new Inbox(registry, HYPER_ID, SALT); + registry.registerChain(SUI_ID, bytes("sui"), bytes32(0), bytes32(0), 1, 0); + registry.registerChain(HYPER_ID, bytes("hyper"), bytes32(0), bytes32(0), 0, 12); + registry.registerGroupKey( + 1, Envelope.SCHEME_ECDSA_SECP256K1, abi.encodePacked(vm.addr(GROUP_PK)) + ); + + // Home: 18-decimal native token, escrow locker. + homeToken = new WrappedToken("Home", "H", 18, admin); + homeLocker = new Locker(outbox, address(inbox), ASSET, address(homeToken), Locker.Mode.Escrow, 18, admin); + + // Foreign: 6-decimal wrapped token, mint locker. No unbacked seed — the + // only way to hold wrapped is via a delivered mint (keeps the supply + // invariant honest). + wrapped = new WrappedToken("Wrapped", "W", 6, admin); + foreignLocker = new Locker(outbox, address(inbox), ASSET, address(wrapped), Locker.Mode.Mint, 6, admin); + wrapped.transferOwnership(address(foreignLocker)); + + // Peers keyed by the *sibling* chain id (SUI_ID here). + homeLocker.setPeer(SUI_ID, _id(address(foreignLocker))); + foreignLocker.setPeer(SUI_ID, _id(address(homeLocker))); + + homeToken.mint(user, 10e18); + } + + // --- helpers --- + + function _id(address a) internal pure returns (bytes32) { + return Message.addressToBytes32(a); + } + + function _payload(uint64 wireAmount, address to) internal pure returns (bytes memory) { + return TransferPayload.encode(TransferPayload.Data(ASSET, wireAmount, bytes32(uint256(uint160(to))))); + } + + // --- outbound --- + + function test_lock_escrows_and_scales_to_wire() public { + vm.startPrank(user); + homeToken.approve(address(homeLocker), 1e18); + homeLocker.lock(1e18, SUI_ID, _id(recipient)); + vm.stopPrank(); + + assertEq(homeToken.balanceOf(address(homeLocker)), 1e18); + assertEq(homeLocker.escrowed(), 1e18); + assertEq(outbox.nextNonce(SUI_ID), 1); // one message committed + } + + function test_lock_rejects_dust() public { + vm.startPrank(user); + homeToken.approve(address(homeLocker), type(uint256).max); + // 1e18 + 1 is not divisible by 10^(18-8)=1e10 → dust. + vm.expectRevert(Locker.AmountHasDust.selector); + homeLocker.lock(1e18 + 1, SUI_ID, _id(recipient)); + vm.stopPrank(); + } + + function test_lock_on_mint_locker_reverts_wrong_mode() public { + vm.expectRevert(Locker.WrongMode.selector); + foreignLocker.lock(1e6, SUI_ID, _id(recipient)); + } + + function test_bridgeOut_unknown_peer_reverts() public { + vm.startPrank(user); + homeToken.approve(address(homeLocker), 1e18); + vm.expectRevert(abi.encodeWithSelector(Locker.UnknownPeer.selector, uint32(999))); + homeLocker.lock(1e18, 999, _id(recipient)); + vm.stopPrank(); + } + + function test_burn_on_foreign_scales_from_local() public { + // Obtain wrapped via a real delivery first (5e6 = 5.0 tokens at 6-dec). + vm.prank(address(inbox)); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(5e8, user)); + assertEq(wrapped.balanceOf(user), 5e6); + + vm.prank(user); + foreignLocker.burn(1e6, SUI_ID, _id(recipient)); // 1.0 token at 6 decimals + assertEq(wrapped.balanceOf(user), 4e6); + assertEq(wrapped.totalSupply(), 4e6); + assertEq(outbox.nextNonce(SUI_ID), 1); // burn committed one outbound message + } + + // --- inbound --- + + function test_inbound_release_escrow() public { + // Fund escrow first (user locks), then a return message releases it. + vm.startPrank(user); + homeToken.approve(address(homeLocker), 1e18); + homeLocker.lock(1e18, SUI_ID, _id(recipient)); + vm.stopPrank(); + + vm.prank(address(inbox)); + homeLocker.onReceive(SUI_ID, _id(address(foreignLocker)), _payload(1e8, recipient)); + + assertEq(homeToken.balanceOf(recipient), 1e18); // 1e8 wire → 1e18 local (18-dec) + assertEq(homeLocker.escrowed(), 0); + } + + function test_inbound_mint_foreign() public { + vm.prank(address(inbox)); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, recipient)); + assertEq(wrapped.balanceOf(recipient), 1e6); // 1e8 wire → 1e6 local (6-dec) + } + + function test_onReceive_only_inbox() public { + vm.expectRevert(Locker.NotInbox.selector); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, recipient)); + } + + function test_onReceive_peer_mismatch() public { + vm.prank(address(inbox)); + vm.expectRevert(Locker.PeerMismatch.selector); + foreignLocker.onReceive(SUI_ID, _id(makeAddr("imposter")), _payload(1e8, recipient)); + } + + function test_onReceive_asset_mismatch() public { + bytes memory bad = TransferPayload.encode( + TransferPayload.Data(bytes32(uint256(1)), 1e8, bytes32(uint256(uint160(recipient)))) + ); + vm.prank(address(inbox)); + vm.expectRevert(Locker.AssetMismatch.selector); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), bad); + } + + // --- rate limit queue + claim --- + + function test_rate_limit_queues_over_cap_and_claims_after_window() public { + vm.warp(10_000); + foreignLocker.setRateLimit(1000, 1e8); // window 1000s, cap 1e8 wire + + // First delivery within cap → mints immediately. + vm.prank(address(inbox)); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, recipient)); + assertEq(wrapped.balanceOf(recipient), 1e6); + + // Second delivery exceeds the window cap → queued, NOT reverted, no mint. + vm.prank(address(inbox)); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, user)); + assertEq(wrapped.balanceOf(user), 0); // queued, nothing minted yet + (address qr, uint64 qa, uint64 unlockAt, bool claimed) = foreignLocker.queued(0); + assertEq(qr, user); + assertEq(qa, 1e8); + assertEq(unlockAt, 11_000); + assertFalse(claimed); + + // Claim before unlock → reverts; after the window → mints. + vm.expectRevert(Locker.StillLocked.selector); + foreignLocker.claim(0); + + vm.warp(11_000); + foreignLocker.claim(0); + assertEq(wrapped.balanceOf(user), 1e6); // now delivered + + // Double claim guarded. + vm.expectRevert(Locker.BadQueueEntry.selector); + foreignLocker.claim(0); + } + + // --- pause --- + + function test_pause_blocks_in_and_out() public { + foreignLocker.setPaused(true); + + vm.prank(user); + vm.expectRevert(Locker.Paused.selector); + foreignLocker.burn(1e6, SUI_ID, _id(recipient)); + + vm.prank(address(inbox)); + vm.expectRevert(Locker.Paused.selector); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, recipient)); + } + + function test_setPeer_only_admin() public { + vm.prank(makeAddr("stranger")); + vm.expectRevert(Locker.NotAdmin.selector); + foreignLocker.setPeer(SUI_ID, _id(address(homeLocker))); + } + + function test_transferAdmin_hands_over_control() public { + address gov = makeAddr("gov"); + foreignLocker.transferAdmin(gov); + assertEq(foreignLocker.admin(), gov); + // old admin can no longer act + vm.expectRevert(Locker.NotAdmin.selector); + foreignLocker.setPaused(true); + // new admin can + vm.prank(gov); + foreignLocker.setPaused(true); + assertTrue(foreignLocker.paused()); + } + + // --- supply invariant across a round trip --- + + function test_supply_invariant_round_trip() public { + // 1) Lock 1e18 on home → escrow 1e18 (= 1e8 wire). + vm.startPrank(user); + homeToken.approve(address(homeLocker), 1e18); + homeLocker.lock(1e18, SUI_ID, _id(recipient)); + vm.stopPrank(); + + // 2) Deliver to foreign → mint 1e6 wrapped (= 1e8 wire). + vm.prank(address(inbox)); + foreignLocker.onReceive(SUI_ID, _id(address(homeLocker)), _payload(1e8, recipient)); + + // Invariant: wrapped supply (wire) <= escrow (wire). Here equal. + assertEq(wrapped.totalSupply(), 1e6); + assertEq(homeLocker.escrowed(), 1e18); + + // 3) Burn on foreign → send back. + vm.prank(recipient); + foreignLocker.burn(1e6, SUI_ID, _id(recipient)); + assertEq(wrapped.totalSupply(), 0); + + // 4) Deliver back to home → release 1e18, escrow drains to 0. + vm.prank(address(inbox)); + homeLocker.onReceive(SUI_ID, _id(address(foreignLocker)), _payload(1e8, recipient)); + assertEq(homeToken.balanceOf(recipient), 1e18); + assertEq(homeLocker.escrowed(), 0); + } + + // --- end-to-end through the real Inbox (ECDSA verify → dispatch → mint) --- + + function test_e2e_inbox_dispatch_mints() public { + Message.CrossChainMessage memory m = Message.CrossChainMessage({ + version: Message.VERSION, + srcChainId: SUI_ID, + dstChainId: HYPER_ID, + nonce: 0, + srcApp: _id(address(homeLocker)), + dstApp: _id(address(foreignLocker)), + payload: _payload(1e8, recipient) + }); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(GROUP_PK, Message.hash(m, Message.deriveDomainSep(SALT))); + Envelope.SignatureEnvelope memory env = Envelope.SignatureEnvelope({ + schemeTag: Envelope.SCHEME_ECDSA_SECP256K1, + groupPubkeyId: 1, + signature: abi.encodePacked(r, s, v) + }); + + inbox.receiveMessage(m, env); + assertEq(wrapped.balanceOf(recipient), 1e6); + assertTrue(inbox.consumed(Message.hash(m, Message.deriveDomainSep(SALT)))); + } +} diff --git a/sui-bridge-contracts/solidity/test/Message.t.sol b/sui-bridge-contracts/solidity/test/Message.t.sol new file mode 100644 index 00000000..3c149ab1 --- /dev/null +++ b/sui-bridge-contracts/solidity/test/Message.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ChainId} from "../src/libraries/ChainId.sol"; +import {Message} from "../src/libraries/Message.sol"; + +contract MessageTest is Test { + bytes32 constant SRC_APP = 0xabababababababababababababababababababababababababababababababab; + bytes32 constant DST_APP = 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd; + /// Shared cross-language test salt (0x01*32); DOMAIN_SEP mirrors it. + bytes32 constant TEST_SALT = 0x0101010101010101010101010101010101010101010101010101010101010101; + bytes32 immutable DOMAIN_SEP = Message.deriveDomainSep(TEST_SALT); + + function _vectorMessage() internal pure returns (Message.CrossChainMessage memory) { + return Message.CrossChainMessage({ + version: Message.VERSION, + srcChainId: ChainId.encode(ChainId.FAMILY_EVM, 998), // 268436454 + dstChainId: ChainId.encode(ChainId.FAMILY_SUI, 0), // 134217728 + nonce: 7, + srcApp: SRC_APP, + dstApp: DST_APP, + payload: bytes("hello-bridge") + }); + } + + /// Digest parity with the Move side under the shared TEST_SALT: this exact + /// message hashes to the same value in + /// `sui_bridge::message_tests::known_digest_vector` and the bridge-types + /// `known_digest_vector`. If any encoding drifts, a signature made for one + /// chain fails on the other. + function test_known_digest_matches_sui() public view { + bytes32 expected = 0x535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707; + assertEq(Message.hash(_vectorMessage(), DOMAIN_SEP), expected); + } + + function test_encode_length_is_fixed_header_plus_payload() public pure { + Message.CrossChainMessage memory m = _vectorMessage(); + bytes memory enc = Message.encode(m); + // 1 + 4 + 4 + 8 + 32 + 32 + 4 + payload. + assertEq(enc.length, 1 + 4 + 4 + 8 + 32 + 32 + 4 + m.payload.length); + } + + function test_hash_is_field_sensitive() public view { + Message.CrossChainMessage memory a = _vectorMessage(); + Message.CrossChainMessage memory b = _vectorMessage(); + b.nonce = 8; + assertTrue(Message.hash(a, DOMAIN_SEP) != Message.hash(b, DOMAIN_SEP)); + } + + function test_hash_is_domain_separated() public pure { + bytes32 sepA = Message.deriveDomainSep(bytes32(uint256(1))); + bytes32 sepB = Message.deriveDomainSep(bytes32(uint256(2))); + assertTrue(Message.hash(_vectorMessage(), sepA) != Message.hash(_vectorMessage(), sepB)); + } + + function test_encode_rejects_bad_version() public { + Message.CrossChainMessage memory m = _vectorMessage(); + m.version = 2; + vm.expectRevert(abi.encodeWithSelector(Message.BadVersion.selector, uint8(2))); + this.encodeExt(m); + } + + function test_address_bytes32_round_trip() public pure { + address a = address(0x1234567890AbcdEF1234567890aBcdef12345678); + assertEq(Message.bytes32ToAddress(Message.addressToBytes32(a)), a); + } + + function encodeExt(Message.CrossChainMessage calldata m) external pure returns (bytes memory) { + return Message.encode(m); + } +} diff --git a/sui-bridge-contracts/solidity/test/Messaging.t.sol b/sui-bridge-contracts/solidity/test/Messaging.t.sol new file mode 100644 index 00000000..65c514bd --- /dev/null +++ b/sui-bridge-contracts/solidity/test/Messaging.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ChainId} from "../src/libraries/ChainId.sol"; +import {Envelope} from "../src/libraries/Envelope.sol"; +import {Message} from "../src/libraries/Message.sol"; +import {Registry} from "../src/Registry.sol"; +import {Outbox} from "../src/Outbox.sol"; +import {Inbox} from "../src/Inbox.sol"; +import {IMessageRecipient} from "../src/interfaces/IMessageRecipient.sol"; + +contract MockRecipient is IMessageRecipient { + uint32 public lastSrcChainId; + bytes32 public lastSrcApp; + bytes public lastPayload; + uint256 public calls; + + function onReceive(uint32 srcChainId, bytes32 srcApp, bytes calldata payload) external { + lastSrcChainId = srcChainId; + lastSrcApp = srcApp; + lastPayload = payload; + calls++; + } +} + +contract MessagingTest is Test { + Registry registry; + Outbox outbox; + Inbox inbox; + MockRecipient recipient; + + address governance = address(this); + address guardian = makeAddr("guardian"); + + // Threshold group key (1-of-1 launch posture). vm.sign signs with `groupPk`; + // its address is what the registry stores and `ecrecover` must match. + uint256 constant GROUP_PK = 0xA11CE; + uint32 constant GROUP_KEY_ID = 1; + + uint32 immutable SUI_ID = ChainId.encode(ChainId.FAMILY_SUI, 0); + uint32 immutable HYPER_ID = ChainId.encode(ChainId.FAMILY_EVM, 998); + + bytes32 constant SUI_PEER = 0x1111111111111111111111111111111111111111111111111111111111111111; + bytes32 constant TEST_SALT = 0x0101010101010101010101010101010101010101010101010101010101010101; + bytes32 domainSep; + + function setUp() public { + registry = new Registry(governance, guardian); + // This Inbox/Outbox live on HyperEVM. + inbox = new Inbox(registry, HYPER_ID, TEST_SALT); + outbox = new Outbox(registry, HYPER_ID, TEST_SALT); + domainSep = Message.deriveDomainSep(TEST_SALT); + recipient = new MockRecipient(); + + registry.registerChain(SUI_ID, bytes("sui-testnet"), bytes32(0), bytes32(0), 1, 0); + registry.registerChain(HYPER_ID, bytes("hyperevm-testnet"), bytes32(0), bytes32(0), 0, 12); + registry.registerGroupKey( + GROUP_KEY_ID, Envelope.SCHEME_ECDSA_SECP256K1, abi.encodePacked(vm.addr(GROUP_PK)) + ); + } + + // --- helpers --- + + function _message(uint64 nonce, bytes memory payload) + internal + view + returns (Message.CrossChainMessage memory) + { + return Message.CrossChainMessage({ + version: Message.VERSION, + srcChainId: SUI_ID, + dstChainId: HYPER_ID, + nonce: nonce, + srcApp: SUI_PEER, + dstApp: Message.addressToBytes32(address(recipient)), + payload: payload + }); + } + + function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } + + function _envelope(uint256 pk, Message.CrossChainMessage memory m) + internal + view + returns (Envelope.SignatureEnvelope memory) + { + return Envelope.SignatureEnvelope({ + schemeTag: Envelope.SCHEME_ECDSA_SECP256K1, + groupPubkeyId: GROUP_KEY_ID, + signature: _sign(pk, Message.hash(m, domainSep)) + }); + } + + // --- inbox: receive / dispatch --- + + function test_receive_delivers_valid_signature() public { + Message.CrossChainMessage memory m = _message(0, bytes("payload-bytes")); + bytes32 h = Message.hash(m, domainSep); + + vm.expectEmit(true, false, false, true, address(inbox)); + emit Inbox.MessageDelivered(h, SUI_ID, 0); + inbox.receiveMessage(m, _envelope(GROUP_PK, m)); + + assertEq(recipient.calls(), 1); + assertEq(recipient.lastSrcChainId(), SUI_ID); + assertEq(recipient.lastSrcApp(), SUI_PEER); + assertEq(recipient.lastPayload(), bytes("payload-bytes")); + assertTrue(inbox.consumed(h)); + assertEq(inbox.highestNonce(SUI_ID), 0); + } + + function test_receive_rejects_replay() public { + Message.CrossChainMessage memory m = _message(0, bytes("p")); + Envelope.SignatureEnvelope memory env = _envelope(GROUP_PK, m); + inbox.receiveMessage(m, env); + + vm.expectRevert( + abi.encodeWithSelector(Inbox.AlreadyConsumed.selector, Message.hash(m, domainSep)) + ); + inbox.receiveMessage(m, env); + } + + function test_receive_rejects_bad_signature() public { + Message.CrossChainMessage memory m = _message(0, bytes("p")); + // Sign with a different key → ecrecover != group address. + Envelope.SignatureEnvelope memory env = _envelope(0xBADBAD, m); + vm.expectRevert(Envelope.InvalidSignature.selector); + inbox.receiveMessage(m, env); + } + + function test_receive_rejects_wrong_dst_chain() public { + Message.CrossChainMessage memory m = _message(0, bytes("p")); + m.dstChainId = SUI_ID; // not this inbox's chain + vm.expectRevert( + abi.encodeWithSelector(Inbox.WrongDstChain.selector, HYPER_ID, SUI_ID) + ); + inbox.receiveMessage(m, _envelope(GROUP_PK, m)); + } + + function test_receive_rejects_when_paused() public { + vm.prank(guardian); + inbox.setPaused(true); + + Message.CrossChainMessage memory m = _message(0, bytes("p")); + vm.expectRevert(Inbox.InboxPaused.selector); + inbox.receiveMessage(m, _envelope(GROUP_PK, m)); + } + + function test_receive_rejects_scheme_key_mismatch() public { + // Register an Ed25519 key under a new id, but present an ECDSA envelope. + registry.registerGroupKey(2, Envelope.SCHEME_ED25519, abi.encodePacked(vm.addr(GROUP_PK))); + Message.CrossChainMessage memory m = _message(0, bytes("p")); + Envelope.SignatureEnvelope memory env = _envelope(GROUP_PK, m); + env.groupPubkeyId = 2; // registered scheme 0, envelope says 1 + vm.expectRevert( + abi.encodeWithSelector( + Inbox.SchemeKeyMismatch.selector, + Envelope.SCHEME_ED25519, + Envelope.SCHEME_ECDSA_SECP256K1 + ) + ); + inbox.receiveMessage(m, env); + } + + // --- outbox: send --- + + function test_outbox_send_assigns_nonce_and_matching_hash() public { + bytes32 dstApp = SUI_PEER; + (uint64 n0, bytes32 h0) = outbox.send(SUI_ID, dstApp, bytes("first")); + (uint64 n1,) = outbox.send(SUI_ID, dstApp, bytes("second")); + assertEq(n0, 0); + assertEq(n1, 1); + assertEq(outbox.nextNonce(SUI_ID), 2); + + Message.CrossChainMessage memory expected = Message.CrossChainMessage({ + version: Message.VERSION, + srcChainId: HYPER_ID, + dstChainId: SUI_ID, + nonce: 0, + srcApp: Message.addressToBytes32(address(this)), + dstApp: dstApp, + payload: bytes("first") + }); + assertEq(h0, Message.hash(expected, domainSep)); + } + + function test_outbox_send_blocked_when_paused() public { + vm.prank(guardian); + outbox.setPaused(true); + vm.expectRevert(Outbox.OutboxPaused.selector); + outbox.send(SUI_ID, SUI_PEER, bytes("x")); + } + + function test_outbox_setPaused_only_guardian() public { + vm.expectRevert(Outbox.NotGuardian.selector); + outbox.setPaused(true); + } + + // --- registry --- + + function test_registry_duplicate_chain_reverts() public { + vm.expectRevert( + abi.encodeWithSelector(Registry.ChainAlreadyRegistered.selector, SUI_ID) + ); + registry.registerChain(SUI_ID, bytes("dup"), bytes32(0), bytes32(0), 1, 0); + } + + function test_registry_register_only_governance() public { + vm.prank(makeAddr("stranger")); + vm.expectRevert(Registry.NotGovernance.selector); + registry.registerChain(99, bytes(""), bytes32(0), bytes32(0), 0, 0); + } + + function test_registry_rejects_family_zero_id() public { + // internalId < 2^27 → family bits = 0 → UnknownFamily. + vm.expectRevert(abi.encodeWithSelector(ChainId.UnknownFamily.selector, uint8(0))); + registry.registerChain(123, bytes(""), bytes32(0), bytes32(0), 0, 0); + } +} diff --git a/sui-bridge-contracts/solidity/test/TransferPayload.t.sol b/sui-bridge-contracts/solidity/test/TransferPayload.t.sol new file mode 100644 index 00000000..5b45b86f --- /dev/null +++ b/sui-bridge-contracts/solidity/test/TransferPayload.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {TransferPayload} from "../src/libraries/TransferPayload.sol"; + +contract TransferPayloadTest is Test { + bytes32 constant ASSET = 0x1111111111111111111111111111111111111111111111111111111111111111; + bytes32 constant RECIP = 0x2222222222222222222222222222222222222222222222222222222222222222; + + /// Parity vector: identical bytes to the Move + Rust `known_encoding_vector`. + function test_known_encoding_vector() public pure { + TransferPayload.Data memory d = TransferPayload.Data(ASSET, 123_456_789, RECIP); + bytes memory enc = TransferPayload.encode(d); + assertEq( + enc, + hex"111111111111111111111111111111111111111111111111111111111111111100000000075bcd152222222222222222222222222222222222222222222222222222222222222222" + ); + assertEq(enc.length, 72); + } + + function test_round_trips() public pure { + TransferPayload.Data memory d = TransferPayload.Data(ASSET, type(uint64).max, RECIP); + TransferPayload.Data memory back = TransferPayload.decode(TransferPayload.encode(d)); + assertEq(back.assetId, d.assetId); + assertEq(back.amount, d.amount); + assertEq(back.recipient, d.recipient); + } + + function test_rejects_bad_length() public { + vm.expectRevert(abi.encodeWithSelector(TransferPayload.BadPayloadLength.selector, uint256(71))); + this.decodeExt(new bytes(71)); + } + + function decodeExt(bytes calldata b) external pure returns (TransferPayload.Data memory) { + return TransferPayload.decode(b); + } +} diff --git a/sui-bridge-contracts/sui-locker/.gitignore b/sui-bridge-contracts/sui-locker/.gitignore new file mode 100644 index 00000000..33763f06 --- /dev/null +++ b/sui-bridge-contracts/sui-locker/.gitignore @@ -0,0 +1,13 @@ +# Move build artifacts +build/ + +# Move coverage / trace output +*.mvcov +.coverage_map.mvcov +.trace + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/sui-bridge-contracts/sui-locker/Move.lock b/sui-bridge-contracts/sui-locker/Move.lock new file mode 100644 index 00000000..3b31f71e --- /dev/null +++ b/sui-bridge-contracts/sui-locker/Move.lock @@ -0,0 +1,29 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.locker] +source = { root = true } +use_environment = "testnet" +manifest_digest = "549471E2DA083A3715932E492CF16899A54AEF5A7241E185FACC7C13D7DBB428" +deps = { std = "MoveStdlib", sui = "Sui", sui_bridge = "sui_bridge" } + +[pinned.testnet.sui_bridge] +source = { local = "../sui" } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sui-bridge-contracts/sui-locker/Move.toml b/sui-bridge-contracts/sui-locker/Move.toml new file mode 100644 index 00000000..5f045adb --- /dev/null +++ b/sui-bridge-contracts/sui-locker/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "locker" +version = "0.0.1" +edition = "2024.beta" + +# Layer 2 — Lock-and-Mint Bridge (Sui side), built on top of the L1 messaging +# package (../sui). See ../../bridge-spec.md §3 and ../relayer-dispatch-design.md. + +[dependencies] +sui_bridge = { local = "../sui" } diff --git a/sui-bridge-contracts/sui-locker/Published.toml b/sui-bridge-contracts/sui-locker/Published.toml new file mode 100644 index 00000000..14c22f72 --- /dev/null +++ b/sui-bridge-contracts/sui-locker/Published.toml @@ -0,0 +1,12 @@ +# Generated by Move +# This file contains metadata about published versions of this package in different environments +# This file SHOULD be committed to source control + +[published.testnet] +chain-id = "4c78adac" +published-at = "0x3ef9871fa5f93ac300317d3b240c85ee8f59f504de255e8e4c3f13b8d404160b" +original-id = "0x3ef9871fa5f93ac300317d3b240c85ee8f59f504de255e8e4c3f13b8d404160b" +version = 1 +toolchain-version = "1.71.1" +build-config = { flavor = "sui", edition = "2024" } +upgrade-capability = "0xabc5d592709c7850979ee7e1d6b58833249690d5fcf0f2131892c997729624f7" diff --git a/sui-bridge-contracts/sui-locker/README.md b/sui-bridge-contracts/sui-locker/README.md new file mode 100644 index 00000000..a0c8b01b --- /dev/null +++ b/sui-bridge-contracts/sui-locker/README.md @@ -0,0 +1,33 @@ +# sui-bridge-contracts/sui-locker + +Sui (Move) side of **Layer 2 — the lock-and-mint Locker** (bridge-spec.md §3), +one deployment per asset, built on the L1 messaging package ([`../sui`](../sui)). + +``` +cd sui-bridge-contracts/sui-locker && sui move test +``` + +## Modules + +| Module | Responsibility | +|--------|----------------| +| [`transfer_payload.move`](sources/transfer_payload.move) | `TransferPayload{asset_id, amount, recipient}` codec — 72-byte fixed layout, byte-identical to the Rust + Solidity codecs | +| [`locker.move`](sources/locker.move) | `Locker` (escrow vault / wrapped `TreasuryCap`), `bridge_out`, the `bridge_receive` convention entry, peer/pause/rate-limit governance | + +## Design + +- **`Locker` with a `Vault` enum:** `Escrow(Balance)` on the home chain, + `Mint(TreasuryCap)` on the foreign chain — one type, mode at creation. +- **Inbound via the `bridge_receive` convention** (see + [`../relayer-dispatch-design.md`](../relayer-dispatch-design.md)): the relayer + reads the Locker object's type to learn `(package, module, T)` and calls + `bridge_receive`, which drives `inbox::receive` + `consume(&self.id)` and then + releases (home) or mints (foreign). No app-specific relayer needed. +- **Safety on every inbound:** `src_app` must equal the registered peer, + `asset_id` must match, a windowed rate limit caps minted/released volume, and + amounts are scaled between local decimals and the shared wire precision + (`WIRE_DECIMALS = 8`), rejecting dust (NTT trimmed-amount). + +The supply invariant `wrapped_supply ≤ locked_collateral` holds by construction: +foreign mint only on a delivered message, home release only on a delivered +message, each consumed exactly once by the L1 Inbox. diff --git a/sui-bridge-contracts/sui-locker/sources/locker.move b/sui-bridge-contracts/sui-locker/sources/locker.move new file mode 100644 index 00000000..3a966194 --- /dev/null +++ b/sui-bridge-contracts/sui-locker/sources/locker.move @@ -0,0 +1,444 @@ +/// Layer 2 — Lock-and-Mint Locker (Sui side), one deployment per asset +/// (bridge-spec.md §3). Built on the L1 messaging package. +/// +/// - Home chain: `Vault::Escrow` holds a `Balance` of the native asset. +/// - Foreign chain: `Vault::Mint` holds the wrapped coin's `TreasuryCap`. +/// +/// Inbound transfers over the per-window rate-limit cap are queued (never +/// reverted) and released by a permissionless `claim` after the window (§3.5) — +/// matching the EVM Locker's behavior. +/// +/// Inbound delivery uses the standard `bridge_receive` convention +/// (../relayer-dispatch-design.md): the relayer reads the Locker object's type +/// to learn `(package, module, T)` and calls `bridge_receive`, which drives +/// `inbox::receive` + `consume(&self.id)` internally — no app-specific relayer. +module locker::locker; + +use sui::balance::{Self, Balance}; +use sui::clock::Clock; +use sui::coin::{Self, Coin, TreasuryCap}; +use sui::event; +use sui::table::{Self, Table}; + +use sui_bridge::envelope; +use sui_bridge::inbox::{Self, Inbox}; +use sui_bridge::message; +use sui_bridge::outbox::{Self, Outbox}; +use sui_bridge::registry::GroupKeyRegistry; + +use locker::transfer_payload; + +const BYTES32_LEN: u64 = 32; + +const EBadBytes32Length: u64 = 1; +const EWrongVaultMode: u64 = 2; +const EPaused: u64 = 3; +const EUnknownPeer: u64 = 4; +const EPeerMismatch: u64 = 5; +const EAssetMismatch: u64 = 6; +// 7 was ERateLimitExceeded — over-limit transfers now queue instead of abort. +const EAmountHasDust: u64 = 8; +const EAdminCapMismatch: u64 = 9; +const EZeroAmount: u64 = 10; +const EStillLocked: u64 = 11; +const EUnknownQueueEntry: u64 = 12; + +/// Where the asset lives on this chain. +public enum Vault has store { + /// Home chain: escrowed native balance. + Escrow(Balance), + /// Foreign chain: mint/burn authority over the wrapped coin. + Mint(TreasuryCap), +} + +public struct Locker has key { + id: UID, + /// 32-byte asset identifier, cross-checked against every inbound payload. + asset_id: vector, + vault: Vault, + /// Decimals of the local `Coin`; amounts are scaled to/from the shared + /// wire precision (`transfer_payload::wire_decimals`). + local_decimals: u8, + /// chain_id -> 32-byte sibling Locker address (the trusted peer per route). + peers: Table>, + paused: bool, + // Inbound windowed rate limit (wire units). cap == 0 disables it. + rate_limit_window_ms: u64, + rate_limit_cap: u64, + window_start_ms: u64, + window_used: u64, + // Over-limit inbound transfers queue here instead of reverting (§3.5); a + // permissionless `claim` releases them once the window passes. + queued: Table, + next_queue_id: u64, +} + +/// A rate-limited inbound transfer awaiting its unlock time. +public struct QueuedTransfer has store, drop { + recipient: address, + wire_amount: u64, + unlock_at_ms: u64, +} + +public struct LockerAdminCap has key, store { + id: UID, + locker_id: ID, +} + +public struct LockerCreated has copy, drop { + locker_id: ID, + asset_id: vector, + /// 0 = Escrow (home), 1 = Mint (foreign). + mode: u8, +} + +public struct BridgedOut has copy, drop { + locker_id: ID, + dst_chain_id: u32, + nonce: u64, + wire_amount: u64, + recipient: vector, +} + +public struct BridgedIn has copy, drop { + locker_id: ID, + src_chain_id: u32, + wire_amount: u64, + recipient: address, +} + +public struct TransferQueued has copy, drop { + locker_id: ID, + id: u64, + recipient: address, + wire_amount: u64, + unlock_at_ms: u64, +} + +public struct TransferClaimed has copy, drop { + locker_id: ID, + id: u64, + recipient: address, + wire_amount: u64, +} + +// --- creation --- + +/// Home-chain Locker: escrow vault, starts empty. +public fun create_escrow_locker( + asset_id: vector, + local_decimals: u8, + ctx: &mut TxContext, +) { + create(asset_id, Vault::Escrow(balance::zero()), 0, local_decimals, ctx) +} + +/// Foreign-chain Locker: takes ownership of the wrapped coin's `TreasuryCap`. +public fun create_mint_locker( + treasury: TreasuryCap, + asset_id: vector, + local_decimals: u8, + ctx: &mut TxContext, +) { + create(asset_id, Vault::Mint(treasury), 1, local_decimals, ctx) +} + +fun create( + asset_id: vector, + vault: Vault, + mode: u8, + local_decimals: u8, + ctx: &mut TxContext, +) { + assert!(asset_id.length() == BYTES32_LEN, EBadBytes32Length); + let locker = Locker { + id: object::new(ctx), + asset_id, + vault, + local_decimals, + peers: table::new(ctx), + paused: false, + rate_limit_window_ms: 0, + rate_limit_cap: 0, + window_start_ms: 0, + window_used: 0, + queued: table::new(ctx), + next_queue_id: 0, + }; + let locker_id = object::id(&locker); + event::emit(LockerCreated { locker_id, asset_id: locker.asset_id, mode }); + transfer::transfer(LockerAdminCap { id: object::new(ctx), locker_id }, ctx.sender()); + transfer::share_object(locker); +} + +// --- governance --- + +public fun set_peer( + cap: &LockerAdminCap, + locker: &mut Locker, + chain_id: u32, + peer_addr: vector, +) { + assert_admin(cap, locker); + assert!(peer_addr.length() == BYTES32_LEN, EBadBytes32Length); + if (locker.peers.contains(chain_id)) { + *locker.peers.borrow_mut(chain_id) = peer_addr; + } else { + locker.peers.add(chain_id, peer_addr); + }; +} + +public fun set_paused(cap: &LockerAdminCap, locker: &mut Locker, paused: bool) { + assert_admin(cap, locker); + locker.paused = paused; +} + +public fun set_rate_limit( + cap: &LockerAdminCap, + locker: &mut Locker, + window_ms: u64, + cap_amount: u64, +) { + assert_admin(cap, locker); + locker.rate_limit_window_ms = window_ms; + locker.rate_limit_cap = cap_amount; + locker.window_used = 0; +} + +fun assert_admin(cap: &LockerAdminCap, locker: &Locker) { + assert!(cap.locker_id == object::id(locker), EAdminCapMismatch); +} + +// --- outbound: lock (home) / burn (foreign) --- + +/// Send `coin` to `recipient` on `dst_chain_id`. Escrows (home) or burns +/// (foreign), then commits a message to the peer Locker via the Outbox. +public fun bridge_out( + locker: &mut Locker, + outbox: &mut Outbox, + coin: Coin, + dst_chain_id: u32, + recipient: vector, + ): u64 { + assert!(!locker.paused, EPaused); + assert!(recipient.length() == BYTES32_LEN, EBadBytes32Length); + assert!(locker.peers.contains(dst_chain_id), EUnknownPeer); + + let local_amount = coin.value(); + assert!(local_amount > 0, EZeroAmount); + let wire_amount = to_wire(local_amount, locker.local_decimals); + + match (&mut locker.vault) { + Vault::Escrow(bal) => { coin::put(bal, coin); }, + Vault::Mint(cap) => { coin::burn(cap, coin); }, + }; + + let payload = transfer_payload::encode( + &transfer_payload::new(locker.asset_id, wire_amount, recipient), + ); + let peer = *locker.peers.borrow(dst_chain_id); + let (nonce, _hash) = outbox::send(outbox, &locker.id, dst_chain_id, peer, payload); + + event::emit(BridgedOut { + locker_id: object::id(locker), + dst_chain_id, + nonce, + wire_amount, + recipient, + }); + nonce +} + +// --- inbound: the standard delivery convention --- + +/// Standard relayer entry (bridge-spec.md §3.4, dispatch-design §3.1). The +/// `message` and `envelope` are passed as BCS bytes so a generic relayer can +/// supply plain `vector` args (decoded here via `from_bcs`) rather than +/// constructing the structs through chained MoveCalls. Verifies + consumes the +/// message via L1, then releases (home) or mints (foreign). +public fun bridge_receive( + inbox: &mut Inbox, + keys: &GroupKeyRegistry, + self: &mut Locker, + message: vector, + envelope: vector, + clock: &Clock, + ctx: &mut TxContext, +) { + let m = message::from_bcs(message); + let env = envelope::from_bcs(envelope); + let delivered = inbox::receive(inbox, keys, m, env); + let (src_chain_id, src_app, payload) = inbox::consume(inbox, delivered, &self.id); + apply_inbound(self, src_chain_id, src_app, payload, clock, ctx); +} + +/// App-side effects of an inbound message. Split out from `bridge_receive` so it +/// is unit-testable without standing up the L1 Inbox + a real signature. +fun apply_inbound( + self: &mut Locker, + src_chain_id: u32, + src_app: vector, + payload: vector, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(!self.paused, EPaused); + assert!(self.peers.contains(src_chain_id), EUnknownPeer); + assert!(src_app == *self.peers.borrow(src_chain_id), EPeerMismatch); + + let tp = transfer_payload::decode(payload); + assert!(transfer_payload::asset_id(&tp) == self.asset_id, EAssetMismatch); + + let wire_amount = transfer_payload::amount(&tp); + assert!(wire_amount > 0, EZeroAmount); + let recipient = sui::address::from_bytes(transfer_payload::recipient(&tp)); + + if (within_rate_limit(self, wire_amount, clock)) { + deliver(self, recipient, wire_amount, ctx); + event::emit(BridgedIn { + locker_id: object::id(self), + src_chain_id, + wire_amount, + recipient, + }); + } else { + // Over the window cap: queue instead of reverting (§3.5). The message is + // still consumed at the Inbox; only the payout is delayed until `claim`. + let unlock_at_ms = self.window_start_ms + self.rate_limit_window_ms; + let id = self.next_queue_id; + self.next_queue_id = id + 1; + self.queued.add(id, QueuedTransfer { recipient, wire_amount, unlock_at_ms }); + event::emit(TransferQueued { + locker_id: object::id(self), + id, + recipient, + wire_amount, + unlock_at_ms, + }); + } +} + +/// Permissionless release of a queued transfer once its window has passed. The +/// delay was the rate-limit control, so a claim does not consume budget. +public fun claim(self: &mut Locker, queue_id: u64, clock: &Clock, ctx: &mut TxContext) { + assert!(!self.paused, EPaused); + assert!(self.queued.contains(queue_id), EUnknownQueueEntry); + assert!(clock.timestamp_ms() >= self.queued.borrow(queue_id).unlock_at_ms, EStillLocked); + + let QueuedTransfer { recipient, wire_amount, unlock_at_ms: _ } = self.queued.remove(queue_id); + deliver(self, recipient, wire_amount, ctx); + event::emit(TransferClaimed { + locker_id: object::id(self), + id: queue_id, + recipient, + wire_amount, + }); +} + +/// Release/mint `wire_amount` (scaled to local decimals) to `recipient`. +fun deliver(self: &mut Locker, recipient: address, wire_amount: u64, ctx: &mut TxContext) { + let local_amount = from_wire(wire_amount, self.local_decimals); + match (&mut self.vault) { + Vault::Escrow(bal) => { + transfer::public_transfer(coin::take(bal, local_amount, ctx), recipient); + }, + Vault::Mint(cap) => { + transfer::public_transfer(coin::mint(cap, local_amount, ctx), recipient); + }, + }; +} + +/// True (and reserves budget) if `amount` fits the current window cap; false if +/// over — the caller queues. cap == 0 disables the limit (always true). +fun within_rate_limit(self: &mut Locker, amount: u64, clock: &Clock): bool { + if (self.rate_limit_cap == 0) return true; + let now = clock.timestamp_ms(); + if (now >= self.window_start_ms + self.rate_limit_window_ms) { + self.window_start_ms = now; + self.window_used = 0; + }; + if (self.window_used + amount <= self.rate_limit_cap) { + self.window_used = self.window_used + amount; + true + } else { + false + } +} + +// --- decimals scaling (NTT trimmed-amount) --- + +fun to_wire(local_amount: u64, local_decimals: u8): u64 { + let wire = transfer_payload::wire_decimals(); + if (local_decimals >= wire) { + let factor = pow10(local_decimals - wire); + let amt = local_amount as u128; + assert!(amt % factor == 0, EAmountHasDust); + (amt / factor) as u64 + } else { + (((local_amount as u128) * pow10(wire - local_decimals)) as u64) + } +} + +fun from_wire(wire_amount: u64, local_decimals: u8): u64 { + let wire = transfer_payload::wire_decimals(); + if (local_decimals >= wire) { + (((wire_amount as u128) * pow10(local_decimals - wire)) as u64) + } else { + let factor = pow10(wire - local_decimals); + let amt = wire_amount as u128; + assert!(amt % factor == 0, EAmountHasDust); + (amt / factor) as u64 + } +} + +fun pow10(n: u8): u128 { + let mut r = 1u128; + let mut i = 0u8; + while (i < n) { r = r * 10; i = i + 1; }; + r +} + +// --- views --- + +public fun asset_id(locker: &Locker): vector { locker.asset_id } +public fun is_paused(locker: &Locker): bool { locker.paused } +public fun local_decimals(locker: &Locker): u8 { locker.local_decimals } + +/// Whether a queue entry exists (not yet claimed). +public fun is_queued(locker: &Locker, id: u64): bool { locker.queued.contains(id) } + +/// `(recipient, wire_amount, unlock_at_ms)` for a queued transfer. Aborts if the +/// id is unknown (already claimed or never queued). +public fun queued_transfer(locker: &Locker, id: u64): (address, u64, u64) { + assert!(locker.queued.contains(id), EUnknownQueueEntry); + let q = locker.queued.borrow(id); + (q.recipient, q.wire_amount, q.unlock_at_ms) +} + +/// Escrowed balance (home chain). Aborts if this is a Mint locker. +public fun escrowed(locker: &Locker): u64 { + match (&locker.vault) { + Vault::Escrow(bal) => bal.value(), + Vault::Mint(_) => abort EWrongVaultMode, + } +} + +#[test_only] +public fun apply_inbound_for_testing( + self: &mut Locker, + src_chain_id: u32, + src_app: vector, + payload: vector, + clock: &Clock, + ctx: &mut TxContext, +) { + apply_inbound(self, src_chain_id, src_app, payload, clock, ctx) +} + +#[test_only] +public fun fund_escrow_for_testing(self: &mut Locker, c: Coin) { + match (&mut self.vault) { + Vault::Escrow(bal) => coin::put(bal, c), + Vault::Mint(_) => abort EWrongVaultMode, + } +} diff --git a/sui-bridge-contracts/sui-locker/sources/transfer_payload.move b/sui-bridge-contracts/sui-locker/sources/transfer_payload.move new file mode 100644 index 00000000..a23ba0de --- /dev/null +++ b/sui-bridge-contracts/sui-locker/sources/transfer_payload.move @@ -0,0 +1,114 @@ +/// Layer 2 transfer payload — the bytes a Locker carries in +/// `CrossChainMessage.payload` (bridge-spec.md §3.2/§3.3). Byte-identical to the +/// Rust `bridge_types::transfer` and Solidity `TransferPayload` codecs. +/// +/// Fixed big-endian packed layout (72 bytes): +/// asset_id bytes32 32 +/// amount u64 big-endian 8 (wire amount, fixed WIRE_DECIMALS) +/// recipient bytes32 32 +/// +/// `amount` is a wire amount in a shared decimal precision; the Locker scales +/// to/from local decimals (NTT trimmed-amount). This codec just carries the u64. +module locker::transfer_payload; + +const BYTES32_LEN: u64 = 32; +const ENCODED_LEN: u64 = 72; + +/// Shared wire precision for cross-chain amounts. +const WIRE_DECIMALS: u8 = 8; + +const EBadBytes32Length: u64 = 1; +const EBadPayloadLength: u64 = 2; + +public struct TransferPayload has copy, drop, store { + asset_id: vector, + amount: u64, + recipient: vector, +} + +public fun wire_decimals(): u8 { WIRE_DECIMALS } + +public fun new(asset_id: vector, amount: u64, recipient: vector): TransferPayload { + assert!(asset_id.length() == BYTES32_LEN, EBadBytes32Length); + assert!(recipient.length() == BYTES32_LEN, EBadBytes32Length); + TransferPayload { asset_id, amount, recipient } +} + +public fun asset_id(p: &TransferPayload): vector { p.asset_id } +public fun amount(p: &TransferPayload): u64 { p.amount } +public fun recipient(p: &TransferPayload): vector { p.recipient } + +public fun encode(p: &TransferPayload): vector { + let mut out = p.asset_id; + append_u64_be(&mut out, p.amount); + out.append(p.recipient); + out +} + +public fun decode(bytes: vector): TransferPayload { + assert!(bytes.length() == ENCODED_LEN, EBadPayloadLength); + let asset_id = slice(&bytes, 0, BYTES32_LEN); + let amount = read_u64_be(&bytes, BYTES32_LEN); + let recipient = slice(&bytes, 40, BYTES32_LEN); + TransferPayload { asset_id, amount, recipient } +} + +fun append_u64_be(out: &mut vector, v: u64) { + let mut i = 8u8; + while (i > 0) { + i = i - 1; + out.push_back(((v >> (8 * i)) & 0xff) as u8); + }; +} + +fun read_u64_be(b: &vector, off: u64): u64 { + let mut v = 0u64; + let mut i = 0; + while (i < 8) { + v = (v << 8) | (*b.borrow(off + i) as u64); + i = i + 1; + }; + v +} + +fun slice(b: &vector, off: u64, len: u64): vector { + let mut out = vector[]; + let mut i = 0; + while (i < len) { + out.push_back(*b.borrow(off + i)); + i = i + 1; + }; + out +} + +#[test_only] +fun filled(byte: u8, n: u64): vector { + let mut v = vector[]; + let mut i = 0; + while (i < n) { v.push_back(byte); i = i + 1; }; + v +} + +#[test] +fun known_encoding_vector() { + let p = new(filled(0x11, 32), 123_456_789, filled(0x22, 32)); + let expected = + x"111111111111111111111111111111111111111111111111111111111111111100000000075bcd152222222222222222222222222222222222222222222222222222222222222222"; + assert!(encode(&p) == expected, 0); + assert!(encode(&p).length() == ENCODED_LEN, 1); +} + +#[test] +fun round_trips() { + let p = new(filled(0xab, 32), 18446744073709551615, filled(0xcd, 32)); + let d = decode(encode(&p)); + assert!(d.asset_id == p.asset_id, 0); + assert!(d.amount == p.amount, 1); + assert!(d.recipient == p.recipient, 2); +} + +#[test] +#[expected_failure(abort_code = EBadPayloadLength)] +fun rejects_bad_length() { + decode(filled(0, 71)); +} diff --git a/sui-bridge-contracts/sui-locker/tests/locker_tests.move b/sui-bridge-contracts/sui-locker/tests/locker_tests.move new file mode 100644 index 00000000..cf1b5158 --- /dev/null +++ b/sui-bridge-contracts/sui-locker/tests/locker_tests.move @@ -0,0 +1,239 @@ +#[test_only] +module locker::locker_tests; + +use sui::clock; +use sui::coin::{Self, Coin}; +use sui::test_scenario::{Self as ts, Scenario}; + +use locker::locker::{Self, Locker, LockerAdminCap}; +use locker::transfer_payload; + +public struct TEST_COIN has drop {} + +const ADMIN: address = @0xA; +const SRC_CHAIN: u32 = 2; + +fun filled(b: u8, n: u64): vector { + let mut v = vector[]; + let mut i = 0; + while (i < n) { v.push_back(b); i = i + 1; }; + v +} + +fun asset(): vector { filled(0xaa, 32) } +fun peer(): vector { filled(0xbb, 32) } + +fun payload(asset_id: vector, amount: u64, recipient: address): vector { + transfer_payload::encode( + &transfer_payload::new(asset_id, amount, sui::address::to_bytes(recipient)), + ) +} + +/// Create a mint locker (local decimals == wire decimals → 1:1) and register the +/// source peer. Leaves the scenario holding the admin cap + shared Locker. +fun setup_mint(s: &mut Scenario, local_decimals: u8) { + let cap = coin::create_treasury_cap_for_testing(s.ctx()); + locker::create_mint_locker(cap, asset(), local_decimals, s.ctx()); + s.next_tx(ADMIN); + let admin = s.take_from_sender(); + let mut lk = s.take_shared>(); + locker::set_peer(&admin, &mut lk, SRC_CHAIN, peer()); + ts::return_shared(lk); + s.return_to_sender(admin); + s.next_tx(ADMIN); +} + +#[test] +fun inbound_mint_delivers_to_recipient() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); // 1:1 + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xCAFE), &clk, s.ctx()); + + s.next_tx(ADMIN); + let c = s.take_from_address>(@0xCAFE); + assert!(c.value() == 1000, 0); + + coin::burn_for_testing(c); + clk.destroy_for_testing(); + ts::return_shared(lk); + s.end(); +} + +#[test] +fun inbound_escrow_releases_to_recipient() { + let mut s = ts::begin(ADMIN); + // Escrow locker + a separate treasury to fund it. + let mut cap = coin::create_treasury_cap_for_testing(s.ctx()); + locker::create_escrow_locker(asset(), 8, s.ctx()); + s.next_tx(ADMIN); + let admin = s.take_from_sender(); + let mut lk = s.take_shared>(); + locker::set_peer(&admin, &mut lk, SRC_CHAIN, peer()); + locker::fund_escrow_for_testing(&mut lk, coin::mint(&mut cap, 5000, s.ctx())); + + let clk = clock::create_for_testing(s.ctx()); + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xCAFE), &clk, s.ctx()); + assert!(locker::escrowed(&lk) == 4000, 0); + + s.next_tx(ADMIN); + let c = s.take_from_address>(@0xCAFE); + assert!(c.value() == 1000, 1); + + coin::burn_for_testing(c); + clk.destroy_for_testing(); + transfer::public_transfer(cap, ADMIN); + s.return_to_sender(admin); + ts::return_shared(lk); + s.end(); +} + +#[test] +fun inbound_scales_up_for_higher_local_decimals() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 9); // local 9 > wire 8 → ×10 + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xCAFE), &clk, s.ctx()); + + s.next_tx(ADMIN); + let c = s.take_from_address>(@0xCAFE); + assert!(c.value() == 10000, 0); // 1000 wire → 10000 local + + coin::burn_for_testing(c); + clk.destroy_for_testing(); + ts::return_shared(lk); + s.end(); +} + +#[test] +#[expected_failure(abort_code = 5, location = locker)] +fun inbound_rejects_wrong_peer() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + // src_app is not the registered peer. + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, filled(0xee, 32), payload(asset(), 1, @0xCAFE), &clk, s.ctx()); + abort 99 +} + +#[test] +#[expected_failure(abort_code = 6, location = locker)] +fun inbound_rejects_wrong_asset() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(filled(0x99, 32), 1, @0xCAFE), &clk, s.ctx()); + abort 99 +} + +#[test] +fun inbound_over_limit_queues_then_claims() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); // 1:1 + let admin = s.take_from_sender(); + let mut lk = s.take_shared>(); + locker::set_rate_limit(&admin, &mut lk, 1000, 1500); // cap 1500 / 1000ms window + let mut clk = clock::create_for_testing(s.ctx()); + clk.set_for_testing(10_000); // past the initial (t=0) window so it re-anchors + + // First 1000 is within cap → delivered immediately (window re-anchors to 10_000). + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xCAFE), &clk, s.ctx()); + // Second 1000 exceeds the 1500 cap → queued (does NOT abort), nothing minted. + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xBEEF), &clk, s.ctx()); + + assert!(locker::is_queued(&lk, 0), 0); + let (recipient, amount, unlock_at) = locker::queued_transfer(&lk, 0); + assert!(recipient == @0xBEEF, 1); + assert!(amount == 1000, 2); + assert!(unlock_at == 11_000, 3); // window end at enqueue time (10_000 + 1000) + + // Only the first delivery minted so far. + s.next_tx(ADMIN); + let c0 = s.take_from_address>(@0xCAFE); + assert!(c0.value() == 1000, 4); + coin::burn_for_testing(c0); + + // Claim after the window releases the queued amount. + clk.set_for_testing(11_000); + locker::claim(&mut lk, 0, &clk, s.ctx()); + assert!(!locker::is_queued(&lk, 0), 5); + + s.next_tx(ADMIN); + let c1 = s.take_from_address>(@0xBEEF); + assert!(c1.value() == 1000, 6); + coin::burn_for_testing(c1); + + clk.destroy_for_testing(); + s.return_to_sender(admin); + ts::return_shared(lk); + s.end(); +} + +#[test] +#[expected_failure(abort_code = 11, location = locker)] +fun claim_before_unlock_aborts() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); + let admin = s.take_from_sender(); + let mut lk = s.take_shared>(); + locker::set_rate_limit(&admin, &mut lk, 1_000_000, 500); + let mut clk = clock::create_for_testing(s.ctx()); + clk.set_for_testing(10_000); + + // 1000 > cap 500 → queued at id 0. + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xBEEF), &clk, s.ctx()); + // Still inside the window → StillLocked. + locker::claim(&mut lk, 0, &clk, s.ctx()); + abort 99 +} + +#[test] +#[expected_failure(abort_code = 12, location = locker)] +fun claim_unknown_entry_aborts() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + locker::claim(&mut lk, 99, &clk, s.ctx()); + abort 99 +} + +#[test] +#[expected_failure(abort_code = 3, location = locker)] +fun claim_blocked_when_paused() { + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 8); + let admin = s.take_from_sender(); + let mut lk = s.take_shared>(); + locker::set_rate_limit(&admin, &mut lk, 1_000_000, 500); + let mut clk = clock::create_for_testing(s.ctx()); + clk.set_for_testing(10_000); + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1000, @0xBEEF), &clk, s.ctx()); + + locker::set_paused(&admin, &mut lk, true); + clk.set_for_testing(10_000 + 1_000_000); + locker::claim(&mut lk, 0, &clk, s.ctx()); // paused → abort + abort 99 +} + +#[test] +#[expected_failure(abort_code = 8, location = locker)] +fun outbound_rejects_dust() { + // local 6 < wire 8: a local amount not divisible by 10^(8-6)=100 is dust. + // Exercised via the scaling helper through a mint locker bridge_out would + // use; here we hit it through from_wire's inverse on inbound is exact, so we + // assert via apply_inbound with local 6 and a wire amount that doesn't scale. + let mut s = ts::begin(ADMIN); + setup_mint(&mut s, 6); // wire 8 > local 6 → from_wire divides by 100 + let mut lk = s.take_shared>(); + let clk = clock::create_for_testing(s.ctx()); + // 1005 wire / 100 has remainder → dust. + locker::apply_inbound_for_testing(&mut lk, SRC_CHAIN, peer(), payload(asset(), 1005, @0xCAFE), &clk, s.ctx()); + abort 99 +} diff --git a/sui-bridge-contracts/sui/.gitignore b/sui-bridge-contracts/sui/.gitignore new file mode 100644 index 00000000..33763f06 --- /dev/null +++ b/sui-bridge-contracts/sui/.gitignore @@ -0,0 +1,13 @@ +# Move build artifacts +build/ + +# Move coverage / trace output +*.mvcov +.coverage_map.mvcov +.trace + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp diff --git a/sui-bridge-contracts/sui/Move.lock b/sui-bridge-contracts/sui/Move.lock new file mode 100644 index 00000000..e091390b --- /dev/null +++ b/sui-bridge-contracts/sui/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "73dd2c2ba6f9fdb21d7ffde2b50a3f2f0ac39bc1" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.sui_bridge] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sui-bridge-contracts/sui/Move.toml b/sui-bridge-contracts/sui/Move.toml new file mode 100644 index 00000000..e4a017e0 --- /dev/null +++ b/sui-bridge-contracts/sui/Move.toml @@ -0,0 +1,14 @@ +[package] +name = "sui_bridge" +version = "0.0.1" +edition = "2024.beta" + +# Layer 1 — Generic cross-chain messaging (Sui side). +# Outbox / Inbox / chain registry / canonical CrossChainMessage + keccak256 +# digest + Ed25519 group-signature verification. See ../bridge-spec.md §2. +# +# The Sui framework (Sui, MoveStdlib) is injected implicitly by the toolchain; +# the package name `sui_bridge` is the implicit named address (published at the +# package's own ID). + +[dependencies] diff --git a/sui-bridge-contracts/sui/Published.toml b/sui-bridge-contracts/sui/Published.toml new file mode 100644 index 00000000..bade815d --- /dev/null +++ b/sui-bridge-contracts/sui/Published.toml @@ -0,0 +1,12 @@ +# Generated by Move +# This file contains metadata about published versions of this package in different environments +# This file SHOULD be committed to source control + +[published.testnet] +chain-id = "4c78adac" +published-at = "0xe403373522aa0dce645671bbd36ca1e80147c6418e1e4e08c97c8fa224a81253" +original-id = "0xe403373522aa0dce645671bbd36ca1e80147c6418e1e4e08c97c8fa224a81253" +version = 1 +toolchain-version = "1.71.1" +build-config = { flavor = "sui", edition = "2024" } +upgrade-capability = "0xb1158de374a52711213cc9afa584cb0c50f785b9040a7741dd411722755630fb" diff --git a/sui-bridge-contracts/sui/README.md b/sui-bridge-contracts/sui/README.md new file mode 100644 index 00000000..2f27dce7 --- /dev/null +++ b/sui-bridge-contracts/sui/README.md @@ -0,0 +1,58 @@ +# sui-bridge-contracts/sui + +Sui (Move) side of **Layer 1 — Generic Cross-Chain Messaging** from +[`../../bridge-spec.md`](../../bridge-spec.md). This is the on-chain transport: an +Outbox that commits canonical messages, and an Inbox that verifies an +aggregated threshold signature and delivers the payload to a destination app. +It knows nothing about enclaves, DKG, or assets — those live elsewhere. + +``` +cd sui-bridge-contracts/sui && sui move test && sui move build +``` + +## Modules + +| Module | Responsibility | +|--------|----------------| +| [`chain_id.move`](sources/chain_id.move) | Self-describing internal chain id: `(family << 27) \| local`, family = top 5 bits (1=Sui, 2=EVM, 3=Solana, 4=Aptos) | +| [`message.move`](sources/message.move) | `CrossChainMessage` + the fixed big-endian packed canonical encoding + `keccak256` digest (spec §2.2) | +| [`envelope.move`](sources/envelope.move) | `SignatureEnvelope` (`scheme_tag`, `group_pubkey_id`, `signature`) + Ed25519 verify adapter (spec §2.3) | +| [`registry.move`](sources/registry.move) | `ChainRegistry`, `GroupKeyRegistry`, `GuardianCap`/`GovernanceCap` (spec §7) | +| [`outbox.move`](sources/outbox.move) | `send` → nonce + `MessageCommitted` event, pausable (spec §2.4) | +| [`inbox.move`](sources/inbox.move) | `receive`/`consume` → verify + exactly-once + hot-potato dispatch, pausable (spec §2.5) | +| `events.move` · `errors.move` | Event types and abort codes | + +## Key design decisions + +- **Canonical wire format is an explicit big-endian packed layout, not BCS.** + The digest must be byte-identical on Sui and EVM so one signature verifies on + both. BCS (little-endian, ULEB128 lengths) can't be reproduced by an EVM + `abi.encodePacked`; the fixed layout in `message.move` can. The + `known_digest_vector` test pins this against an independent off-chain keccak. + +- **Signers sign the 32-byte `message::hash` digest.** Ed25519 verify runs over + the digest bytes — the same preimage the EVM `ecrecover` path will use. + +- **Dispatch is a two-step hot-potato handshake** (`receive` → `consume`), since + Move has no dynamic dispatch. `receive` verifies and returns an + ability-less `DeliveredMessage`; the destination app discharges it via + `consume(..., app: &UID)`, proving identity because only the app's own module + can produce a `&UID` whose id equals `dst_app`. Replay-marking happens inside + `consume`, atomically with delivery, so an untrusted relayer cannot + consume-without-delivering. + +- **No cross-message ordering** (spec §2.6): the `consumed` hash-set is the sole + exactly-once guard; per-source nonce is tracked for observability, not enforced. + +## Out of scope here (per the spec's milestones) + +- **EVM side** (Solidity Outbox/Inbox) — the `ecrecover`/GG20 verify path. +- **Layer 2 Locker** (lock-and-mint app, wrapped `Coin`) — consumes this + Inbox's `DeliveredMessage` and calls this Outbox's `send`. +- **Signer node / DKG / Seal** — the off-chain Nautilus enclave (→ `rust-backend/`) + and the threshold-crypto ceremony. + +The Ed25519 verify path uses a single registered group key, which is exactly +the **M1 "1-of-1"** posture: a single-party aggregated signature is +indistinguishable on-chain from a k-of-n one, so no contract change is needed +when real threshold signing turns on (spec §1, §6.3). diff --git a/sui-bridge-contracts/sui/sources/chain_id.move b/sui-bridge-contracts/sui/sources/chain_id.move new file mode 100644 index 00000000..0a9df778 --- /dev/null +++ b/sui-bridge-contracts/sui/sources/chain_id.move @@ -0,0 +1,62 @@ +/// Internal chain-id encoding (bridge-spec.md §2.2 — "internal registry ID, +/// NOT the native chainid"). +/// +/// The 32-bit internal id is self-describing: the top 5 bits are the chain +/// *family*, the low 27 bits are a per-family local id. +/// +/// ``` +/// 31 27 26 0 +/// ┌──────────┬─────────────────────────────────┐ +/// │ family │ local id │ +/// │ 5 bits │ 27 bits │ +/// └──────────┴─────────────────────────────────┘ +/// +/// internal_id = (family << 27) | local +/// ``` +/// +/// Families: 1 = Sui, 2 = EVM, 3 = Solana, 4 = Aptos. Recovering the family is +/// a shift (`family(id)`), so no registry lookup or separately-stored field is +/// needed and the two can never disagree. +/// +/// The 27-bit local field caps at 134,217,727. For EVM it SHOULD be the native +/// chainId when it fits (HyperEVM testnet = 998 does); chains whose chainId +/// exceeds the ceiling must use an assigned index instead, with the registry's +/// `native_identifier` always holding the authoritative value. +module sui_bridge::chain_id; + +use sui_bridge::errors; + +const FAMILY_SHIFT: u8 = 27; +const FAMILY_MASK: u32 = 0x1F; // 5 bits +const LOCAL_MASK: u32 = 0x07FF_FFFF; // low 27 bits + +const FAMILY_SUI: u8 = 1; +const FAMILY_EVM: u8 = 2; +const FAMILY_SOLANA: u8 = 3; +const FAMILY_APTOS: u8 = 4; + +public fun family_sui(): u8 { FAMILY_SUI } +public fun family_evm(): u8 { FAMILY_EVM } +public fun family_solana(): u8 { FAMILY_SOLANA } +public fun family_aptos(): u8 { FAMILY_APTOS } + +public fun local_id_ceiling(): u32 { LOCAL_MASK } + +public fun is_valid_family(f: u8): bool { f >= FAMILY_SUI && f <= FAMILY_APTOS } + +/// Compose an internal id from `family` and a 27-bit `local` id. +public fun new(family: u8, local: u32): u32 { + assert!(is_valid_family(family), errors::unknown_family()); + assert!(local <= LOCAL_MASK, errors::chain_local_too_large()); + ((family as u32) << FAMILY_SHIFT) | local +} + +/// The family tag (top 5 bits). +public fun family(internal_id: u32): u8 { + (((internal_id >> FAMILY_SHIFT) & FAMILY_MASK) as u8) +} + +/// The per-family local id (low 27 bits). +public fun local(internal_id: u32): u32 { + internal_id & LOCAL_MASK +} diff --git a/sui-bridge-contracts/sui/sources/envelope.move b/sui-bridge-contracts/sui/sources/envelope.move new file mode 100644 index 00000000..17f122d1 --- /dev/null +++ b/sui-bridge-contracts/sui/sources/envelope.move @@ -0,0 +1,69 @@ +/// Signature envelope + per-scheme verification adapter. +/// +/// The delivered message carries `(scheme_tag, group_pubkey_id, signature)` +/// (bridge-spec.md §2.3). The Inbox looks the registered group key up by +/// `group_pubkey_id` and verifies by `scheme_tag`. Messages destined for Sui +/// are signed with FROST threshold Schnorr over Ed25519, so the Sui adapter +/// implements the Ed25519 path; other scheme tags abort here (an EVM-destined +/// ECDSA signature is verified on the EVM Inbox, not this one). +/// +/// Signers sign over the 32-byte `message::hash` digest, so Ed25519 verify is +/// performed directly over the digest bytes — identical preimage to the EVM +/// ecrecover path. +module sui_bridge::envelope; + +use sui::bcs; +use sui::ed25519; +use sui_bridge::errors; +use sui_bridge::registry::{Self, GroupKeyRegistry}; + +const SCHEME_ED25519: u8 = 0; +const SCHEME_ECDSA_SECP256K1: u8 = 1; + +public fun scheme_ed25519(): u8 { SCHEME_ED25519 } +public fun scheme_ecdsa_secp256k1(): u8 { SCHEME_ECDSA_SECP256K1 } + +public struct SignatureEnvelope has copy, drop, store { + scheme_tag: u8, + group_pubkey_id: u32, + signature: vector, +} + +public fun new(scheme_tag: u8, group_pubkey_id: u32, signature: vector): SignatureEnvelope { + SignatureEnvelope { scheme_tag, group_pubkey_id, signature } +} + +/// Decode the standard BCS serialization (scheme_tag, group_pubkey_id, +/// signature) a relayer passes as a plain `vector` arg. Produced off-chain +/// by `bridge_types::SignatureEnvelope::to_move_bcs`. +public fun from_bcs(bytes: vector): SignatureEnvelope { + let mut b = bcs::new(bytes); + let scheme_tag = b.peel_u8(); + let group_pubkey_id = b.peel_u32(); + let signature = b.peel_vec_u8(); + SignatureEnvelope { scheme_tag, group_pubkey_id, signature } +} + +public fun scheme_tag(e: &SignatureEnvelope): u8 { e.scheme_tag } +public fun group_pubkey_id(e: &SignatureEnvelope): u32 { e.group_pubkey_id } +public fun signature(e: &SignatureEnvelope): vector { e.signature } + +/// Verify `envelope` over `message_hash` against the registered group key. +/// Aborts on an unknown key id, a scheme/key mismatch, an unsupported scheme, +/// or an invalid signature. +public fun verify( + keys: &GroupKeyRegistry, + envelope: &SignatureEnvelope, + message_hash: &vector, +) { + let (registered_scheme, pubkey) = registry::group_key(keys, envelope.group_pubkey_id); + assert!(registered_scheme == envelope.scheme_tag, errors::scheme_key_mismatch()); + + if (envelope.scheme_tag == SCHEME_ED25519) { + let ok = ed25519::ed25519_verify(&envelope.signature, &pubkey, message_hash); + assert!(ok, errors::signature_invalid()); + } else { + // ECDSA/secp256k1 is the EVM-destined path, verified on the EVM Inbox. + abort errors::unsupported_scheme() + } +} diff --git a/sui-bridge-contracts/sui/sources/errors.move b/sui-bridge-contracts/sui/sources/errors.move new file mode 100644 index 00000000..105d52b0 --- /dev/null +++ b/sui-bridge-contracts/sui/sources/errors.move @@ -0,0 +1,30 @@ +/// Centralized abort codes for the Layer 1 messaging package, mirroring the +/// `options_protocol::errors` convention (one `public fun` per code). +module sui_bridge::errors; + +// --- message / encoding --- +public fun bad_bytes32_length(): u64 { 1 } +public fun unsupported_version(): u64 { 2 } + +// --- registry --- +public fun chain_already_registered(): u64 { 3 } +public fun chain_not_registered(): u64 { 4 } +public fun unknown_family(): u64 { 5 } +public fun chain_local_too_large(): u64 { 19 } +public fun group_key_already_registered(): u64 { 6 } +public fun group_key_not_registered(): u64 { 7 } +public fun bad_pubkey_length(): u64 { 8 } +public fun invalid_threshold(): u64 { 9 } + +// --- outbox --- +public fun outbox_paused(): u64 { 10 } + +// --- inbox --- +public fun inbox_paused(): u64 { 11 } +public fun wrong_dst_chain(): u64 { 12 } +public fun message_already_consumed(): u64 { 13 } +public fun nonce_below_window(): u64 { 14 } +public fun signature_invalid(): u64 { 15 } +public fun unsupported_scheme(): u64 { 16 } +public fun dst_app_mismatch(): u64 { 17 } +public fun scheme_key_mismatch(): u64 { 18 } diff --git a/sui-bridge-contracts/sui/sources/events.move b/sui-bridge-contracts/sui/sources/events.move new file mode 100644 index 00000000..55d4efbf --- /dev/null +++ b/sui-bridge-contracts/sui/sources/events.move @@ -0,0 +1,86 @@ +/// Emitted events for the Layer 1 messaging package. Kept in one module per the +/// `options_protocol::events` convention so the indexer has a single source. +module sui_bridge::events; + +use sui::event; + +/// Emitted by `outbox::send`. Carries every canonical `CrossChainMessage` +/// field so the signer group can reconstruct the exact preimage off-chain. +public struct MessageCommitted has copy, drop { + outbox_id: ID, + message_hash: vector, + src_chain_id: u32, + dst_chain_id: u32, + nonce: u64, + src_app: vector, + dst_app: vector, + payload: vector, +} + +/// Emitted by `inbox::consume` once a message is verified and delivered. +public struct MessageDelivered has copy, drop { + inbox_id: ID, + message_hash: vector, + src_chain_id: u32, + nonce: u64, +} + +public struct OutboxPaused has copy, drop { outbox_id: ID, paused: bool } +public struct InboxPaused has copy, drop { inbox_id: ID, paused: bool } + +public struct ChainRegistered has copy, drop { + internal_id: u32, + family: u8, +} + +public struct GroupKeyRegistered has copy, drop { + group_pubkey_id: u32, + scheme_tag: u8, +} + +public(package) fun emit_message_committed( + outbox_id: ID, + message_hash: vector, + src_chain_id: u32, + dst_chain_id: u32, + nonce: u64, + src_app: vector, + dst_app: vector, + payload: vector, +) { + event::emit(MessageCommitted { + outbox_id, + message_hash, + src_chain_id, + dst_chain_id, + nonce, + src_app, + dst_app, + payload, + }); +} + +public(package) fun emit_message_delivered( + inbox_id: ID, + message_hash: vector, + src_chain_id: u32, + nonce: u64, +) { + event::emit(MessageDelivered { inbox_id, message_hash, src_chain_id, nonce }); +} + +public(package) fun emit_outbox_paused(outbox_id: ID, paused: bool) { + event::emit(OutboxPaused { outbox_id, paused }); +} + +public(package) fun emit_inbox_paused(inbox_id: ID, paused: bool) { + event::emit(InboxPaused { inbox_id, paused }); +} + +public(package) fun emit_chain_registered(internal_id: u32, family: u8) { + event::emit(ChainRegistered { internal_id, family }); +} + +public(package) fun emit_group_key_registered(group_pubkey_id: u32, scheme_tag: u8) { + event::emit(GroupKeyRegistered { group_pubkey_id, scheme_tag }); +} diff --git a/sui-bridge-contracts/sui/sources/inbox.move b/sui-bridge-contracts/sui/sources/inbox.move new file mode 100644 index 00000000..584eafab --- /dev/null +++ b/sui-bridge-contracts/sui/sources/inbox.move @@ -0,0 +1,186 @@ +/// Inbox (one per chain): verifies an aggregated threshold signature against +/// the registered group key, enforces exactly-once delivery, and hands the +/// payload to the destination app (bridge-spec.md §2.5). +/// +/// ## Dispatch model (why two steps) +/// +/// Move has no dynamic dispatch, so the Inbox cannot itself call an arbitrary +/// destination package. Delivery is therefore a hot-potato handshake: +/// +/// 1. `receive(...)` verifies signature + dst-chain + dedup and returns a +/// `DeliveredMessage` that has NO abilities — it cannot be dropped, +/// stored, or copied. +/// 2. The destination app discharges it with `consume(..., app: &UID)`, +/// proving its identity by passing a `&UID` whose 32-byte id equals +/// `dst_app`. Only the module that defines that object can produce the +/// reference, so a relayer cannot call `consume` itself. +/// +/// Replay-marking happens in `consume`, atomically with delivery. An untrusted +/// relayer thus cannot consume a message without actually delivering it (the +/// whole PTB aborts and nothing is marked), closing the consume-and-drop grief. +/// +/// No cross-message ordering is enforced (§2.6): the `consumed` hash-set is the +/// sole exactly-once guard; per-source nonce is tracked for observability only. +module sui_bridge::inbox; + +use sui::table::{Self, Table}; +use sui_bridge::envelope::{Self, SignatureEnvelope}; +use sui_bridge::errors; +use sui_bridge::events; +use sui_bridge::message::{Self, CrossChainMessage}; +use sui_bridge::registry::{GovernanceCap, GuardianCap, GroupKeyRegistry}; + +public struct Inbox has key { + id: UID, + /// Internal registry id of THIS chain; every accepted message must target it. + dst_chain_id: u32, + /// Digest domain separator (spec §2.2), derived from the deployment salt. + domain_sep: vector, + /// Exactly-once guard, keyed by the 32-byte canonical message hash. + consumed: Table, bool>, + /// Highest nonce observed per source chain (observability only). + highest_nonce: Table, + paused: bool, +} + +/// Authenticated, verified message awaiting delivery. No abilities: the only +/// way to discharge it is `consume`, which records replay protection. +public struct DeliveredMessage { + src_chain_id: u32, + src_app: vector, + dst_app: vector, + nonce: u64, + payload: vector, + message_hash: vector, +} + +/// Create + share an Inbox bound to this chain's internal id. Governance-gated. +/// `deployment_salt` is the 32-byte per-deployment salt; the digest separator is +/// derived + stored so it is auditable on-chain. +public fun create( + _: &GovernanceCap, + dst_chain_id: u32, + deployment_salt: vector, + ctx: &mut TxContext, +): ID { + let inbox = Inbox { + id: object::new(ctx), + dst_chain_id, + domain_sep: message::derive_domain_sep(deployment_salt), + consumed: table::new(ctx), + highest_nonce: table::new(ctx), + paused: false, + }; + let id = object::id(&inbox); + transfer::share_object(inbox); + id +} + +/// Verify a message + signature envelope and return a `DeliveredMessage` for +/// the destination app to consume. Does NOT mutate the Inbox — replay-marking +/// happens in `consume`. Aborts on pause, wrong dst chain, already-consumed, +/// or an invalid/unknown-key signature. +public fun receive( + inbox: &Inbox, + keys: &GroupKeyRegistry, + message: CrossChainMessage, + envelope: SignatureEnvelope, +): DeliveredMessage { + assert!(!inbox.paused, errors::inbox_paused()); + assert!(message::dst_chain_id(&message) == inbox.dst_chain_id, errors::wrong_dst_chain()); + + let message_hash = message::hash(&message, inbox.domain_sep); + assert!(!inbox.consumed.contains(message_hash), errors::message_already_consumed()); + + envelope::verify(keys, &envelope, &message_hash); + + DeliveredMessage { + src_chain_id: message::src_chain_id(&message), + src_app: message::src_app(&message), + dst_app: message::dst_app(&message), + nonce: message::nonce(&message), + payload: message::payload(&message), + message_hash, + } +} + +/// Discharge a `DeliveredMessage`: prove app identity via `app` (its id must +/// equal `dst_app`), mark the message consumed, and return +/// `(src_chain_id, src_app, payload)` to the caller. The app is responsible for +/// checking `src_app` against its registered peer before acting on `payload`. +public fun consume( + inbox: &mut Inbox, + delivered: DeliveredMessage, + app: &UID, +): (u32, vector, vector) { + let DeliveredMessage { src_chain_id, src_app, dst_app, nonce, payload, message_hash } = delivered; + + assert!(object::uid_to_bytes(app) == dst_app, errors::dst_app_mismatch()); + assert!(!inbox.consumed.contains(message_hash), errors::message_already_consumed()); + + inbox.consumed.add(message_hash, true); + + if (inbox.highest_nonce.contains(src_chain_id)) { + let cur = inbox.highest_nonce.borrow_mut(src_chain_id); + if (nonce > *cur) { *cur = nonce; }; + } else { + inbox.highest_nonce.add(src_chain_id, nonce); + }; + + events::emit_message_delivered(object::id(inbox), message_hash, src_chain_id, nonce); + (src_chain_id, src_app, payload) +} + +// --- DeliveredMessage read-only accessors (inspect before consuming) --- +public fun delivered_src_chain_id(d: &DeliveredMessage): u32 { d.src_chain_id } +public fun delivered_src_app(d: &DeliveredMessage): vector { d.src_app } +public fun delivered_dst_app(d: &DeliveredMessage): vector { d.dst_app } +public fun delivered_nonce(d: &DeliveredMessage): u64 { d.nonce } +public fun delivered_payload(d: &DeliveredMessage): vector { d.payload } +public fun delivered_message_hash(d: &DeliveredMessage): vector { d.message_hash } + +// --- views / admin --- +public fun is_consumed(inbox: &Inbox, message_hash: vector): bool { + inbox.consumed.contains(message_hash) +} + +public fun is_paused(inbox: &Inbox): bool { inbox.paused } +public fun dst_chain_id(inbox: &Inbox): u32 { inbox.dst_chain_id } + +/// Global inbound circuit breaker (§2.7). Guardian-gated. +public fun set_paused(_: &GuardianCap, inbox: &mut Inbox, paused: bool) { + inbox.paused = paused; + events::emit_inbox_paused(object::id(inbox), paused); +} + +/// Build a `DeliveredMessage` straight from a message, bypassing signature +/// verification. Lets `consume` (which needs `dst_app` to equal a runtime +/// object id) be exercised without an offline signature over that id. +#[test_only] +public fun deliver_for_testing( + message: &CrossChainMessage, + domain_sep: vector, +): DeliveredMessage { + DeliveredMessage { + src_chain_id: message::src_chain_id(message), + src_app: message::src_app(message), + dst_app: message::dst_app(message), + nonce: message::nonce(message), + payload: message::payload(message), + message_hash: message::hash(message, domain_sep), + } +} + +/// Discharge a `DeliveredMessage` without consuming it (test cleanup for the +/// `receive` path, whose vectors use a synthetic `dst_app`). +#[test_only] +public fun destroy_delivered_for_testing(d: DeliveredMessage) { + let DeliveredMessage { + src_chain_id: _, + src_app: _, + dst_app: _, + nonce: _, + payload: _, + message_hash: _, + } = d; +} diff --git a/sui-bridge-contracts/sui/sources/message.move b/sui-bridge-contracts/sui/sources/message.move new file mode 100644 index 00000000..4ba6f616 --- /dev/null +++ b/sui-bridge-contracts/sui/sources/message.move @@ -0,0 +1,161 @@ +/// Canonical, chain-neutral cross-chain message and its keccak256 digest. +/// +/// The digest MUST be byte-identical on every chain so the same threshold +/// signature verifies everywhere (bridge-spec.md §2.2). BCS is *not* used for +/// the wire format because it is little-endian + ULEB128 length-prefixed, which +/// an EVM `abi.encodePacked` cannot reproduce. Instead we fix an explicit +/// big-endian packed layout that both Move and Solidity can emit identically: +/// +/// ``` +/// version u8 1 byte +/// src_chain_id u32 big-endian 4 bytes +/// dst_chain_id u32 big-endian 4 bytes +/// nonce u64 big-endian 8 bytes +/// src_app bytes32 32 bytes +/// dst_app bytes32 32 bytes +/// payload_len u32 big-endian 4 bytes (guards against payload ambiguity) +/// payload bytes payload_len bytes +/// ``` +/// +/// `message_hash = keccak256(DOMAIN_SEP || encode(message))`, where +/// `DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deployment_salt)` binds every +/// signed message to one logical deployment (bridge-spec.md §2.2) so a redeploy +/// that reuses registry ids cannot replay previously signed messages. Signers +/// sign over this 32-byte digest (Ed25519 verify over the digest bytes on Sui; +/// ecrecover over the same digest on EVM), keeping "the signed digest is +/// identical everywhere" literally true. +module sui_bridge::message; + +use sui::bcs; +use sui::hash; +use sui_bridge::errors; + +/// Current canonical format version. Start at 1 (bridge-spec.md §2.2). +const VERSION: u8 = 1; + +/// Domain-separation tag hashed with the per-deployment salt to form DOMAIN_SEP. +const DOMAIN_TAG: vector = b"XCHAIN_MSG_V1"; + +const BYTES32_LEN: u64 = 32; + +public struct CrossChainMessage has copy, drop, store { + version: u8, + src_chain_id: u32, + dst_chain_id: u32, + nonce: u64, + /// 32-byte sender app address (Sui object/package ID, or left-padded EVM + /// address). Length is enforced to be exactly 32. + src_app: vector, + /// 32-byte recipient app address (same encoding as `src_app`). + dst_app: vector, + /// Opaque to Layer 1; defined by the destination app. + payload: vector, +} + +public fun version_byte(): u8 { VERSION } + +/// Construct a message with the current format version, validating the +/// bytes32 address fields. +public fun new( + src_chain_id: u32, + dst_chain_id: u32, + nonce: u64, + src_app: vector, + dst_app: vector, + payload: vector, +): CrossChainMessage { + assert!(src_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + assert!(dst_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + CrossChainMessage { + version: VERSION, + src_chain_id, + dst_chain_id, + nonce, + src_app, + dst_app, + payload, + } +} + +// --- field accessors --- +public fun version(m: &CrossChainMessage): u8 { m.version } +public fun src_chain_id(m: &CrossChainMessage): u32 { m.src_chain_id } +public fun dst_chain_id(m: &CrossChainMessage): u32 { m.dst_chain_id } +public fun nonce(m: &CrossChainMessage): u64 { m.nonce } +public fun src_app(m: &CrossChainMessage): vector { m.src_app } +public fun dst_app(m: &CrossChainMessage): vector { m.dst_app } +public fun payload(m: &CrossChainMessage): vector { m.payload } + +/// Canonical big-endian packed serialization (see module doc). +public fun encode(m: &CrossChainMessage): vector { + assert!(m.version == VERSION, errors::unsupported_version()); + assert!(m.src_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + assert!(m.dst_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + + let mut out = vector[]; + out.push_back(m.version); + append_u32_be(&mut out, m.src_chain_id); + append_u32_be(&mut out, m.dst_chain_id); + append_u64_be(&mut out, m.nonce); + out.append(m.src_app); + out.append(m.dst_app); + append_u32_be(&mut out, m.payload.length() as u32); + out.append(m.payload); + out +} + +/// `DOMAIN_SEP = keccak256("XCHAIN_MSG_V1" || deployment_salt)`. Derived once +/// per deployment and stored on the Outbox/Inbox so the separator is auditable +/// on-chain. `deployment_salt` must be 32 bytes. +public fun derive_domain_sep(deployment_salt: vector): vector { + assert!(deployment_salt.length() == BYTES32_LEN, errors::bad_bytes32_length()); + let mut preimage = DOMAIN_TAG; + preimage.append(deployment_salt); + hash::keccak256(&preimage) +} + +/// `keccak256(domain_sep || encode(message))` — the 32-byte digest signers sign +/// over. `domain_sep` is [`derive_domain_sep`] of the deployment salt. +public fun hash(m: &CrossChainMessage, domain_sep: vector): vector { + let mut preimage = domain_sep; + preimage.append(encode(m)); + hash::keccak256(&preimage) +} + +/// Decode the standard BCS serialization of a `CrossChainMessage` (field order: +/// version, src_chain_id, dst_chain_id, nonce, src_app, dst_app, payload). This +/// is what an untrusted relayer passes as a plain `vector` arg so it doesn't +/// have to construct the struct via chained MoveCalls (relayer-dispatch-design +/// §3.1). Produced off-chain by `bridge_types::CrossChainMessage::to_move_bcs`. +public fun from_bcs(bytes: vector): CrossChainMessage { + let mut b = bcs::new(bytes); + let version = b.peel_u8(); + let src_chain_id = b.peel_u32(); + let dst_chain_id = b.peel_u32(); + let nonce = b.peel_u64(); + let src_app = b.peel_vec_u8(); + let dst_app = b.peel_vec_u8(); + let payload = b.peel_vec_u8(); + assert!(version == VERSION, errors::unsupported_version()); + assert!(src_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + assert!(dst_app.length() == BYTES32_LEN, errors::bad_bytes32_length()); + CrossChainMessage { version, src_chain_id, dst_chain_id, nonce, src_app, dst_app, payload } +} + +// --- big-endian integer encoders --- + +public fun append_u32_be(out: &mut vector, v: u32) { + let mut i = 4u8; + while (i > 0) { + i = i - 1; + out.push_back(((v >> (8 * (i as u8))) & 0xff) as u8); + }; +} + +public fun append_u64_be(out: &mut vector, v: u64) { + let mut i = 8u8; + while (i > 0) { + i = i - 1; + out.push_back(((v >> (8 * (i as u8))) & 0xff) as u8); + }; +} diff --git a/sui-bridge-contracts/sui/sources/outbox.move b/sui-bridge-contracts/sui/sources/outbox.move new file mode 100644 index 00000000..4626eb00 --- /dev/null +++ b/sui-bridge-contracts/sui/sources/outbox.move @@ -0,0 +1,103 @@ +/// Outbox (one per chain): apps call `send` to emit a cross-chain message. It +/// assigns a per-destination nonce, computes the canonical keccak256 digest, +/// and emits `MessageCommitted` so the signer group can observe the exact +/// preimage deterministically (bridge-spec.md §2.4). +/// +/// The calling app proves its identity by passing a reference to a `UID` it +/// owns; `src_app` is that object's 32-byte ID. Only the module that defines +/// the object can produce a `&UID` for it, so an app's address cannot be +/// spoofed by a third party. +module sui_bridge::outbox; + +use sui::table::{Self, Table}; +use sui_bridge::errors; +use sui_bridge::events; +use sui_bridge::message; +use sui_bridge::registry::{GovernanceCap, GuardianCap}; + +public struct Outbox has key { + id: UID, + /// Internal registry id of THIS chain (the source for everything it emits). + src_chain_id: u32, + /// Digest domain separator (spec §2.2), derived from the deployment salt. + domain_sep: vector, + /// `next_nonce[dst_chain_id]` — monotonic per (src, dst) lane (§2.6). + next_nonce: Table, + paused: bool, +} + +/// Create + share an Outbox for this chain. Governance-gated. `deployment_salt` +/// is the 32-byte per-deployment salt; the digest separator is derived + stored. +public fun create( + _: &GovernanceCap, + src_chain_id: u32, + deployment_salt: vector, + ctx: &mut TxContext, +): ID { + let outbox = Outbox { + id: object::new(ctx), + src_chain_id, + domain_sep: message::derive_domain_sep(deployment_salt), + next_nonce: table::new(ctx), + paused: false, + }; + let id = object::id(&outbox); + transfer::share_object(outbox); + id +} + +/// Emit a message addressed to `dst_app` on `dst_chain_id`. +/// `app` is the caller's own object, proving `src_app`. Returns the assigned +/// nonce and the canonical message hash. +public fun send( + outbox: &mut Outbox, + app: &UID, + dst_chain_id: u32, + dst_app: vector, + payload: vector, +): (u64, vector) { + assert!(!outbox.paused, errors::outbox_paused()); + + let nonce = next_nonce(outbox, dst_chain_id); + let src_app = object::uid_to_bytes(app); + let m = message::new( + outbox.src_chain_id, + dst_chain_id, + nonce, + src_app, + dst_app, + payload, + ); + let message_hash = message::hash(&m, outbox.domain_sep); + + *outbox.next_nonce.borrow_mut(dst_chain_id) = nonce + 1; + + events::emit_message_committed( + object::id(outbox), + message_hash, + outbox.src_chain_id, + dst_chain_id, + nonce, + src_app, + dst_app, + payload, + ); + (nonce, message_hash) +} + +/// `next_nonce(dst_chain_id)` — lazily initializes the lane to 0. +public fun next_nonce(outbox: &mut Outbox, dst_chain_id: u32): u64 { + if (!outbox.next_nonce.contains(dst_chain_id)) { + outbox.next_nonce.add(dst_chain_id, 0); + }; + *outbox.next_nonce.borrow(dst_chain_id) +} + +public fun is_paused(outbox: &Outbox): bool { outbox.paused } +public fun src_chain_id(outbox: &Outbox): u32 { outbox.src_chain_id } + +/// Global outbound circuit breaker (§2.7). Guardian-gated. +public fun set_paused(_: &GuardianCap, outbox: &mut Outbox, paused: bool) { + outbox.paused = paused; + events::emit_outbox_paused(object::id(outbox), paused); +} diff --git a/sui-bridge-contracts/sui/sources/registry.move b/sui-bridge-contracts/sui/sources/registry.move new file mode 100644 index 00000000..bc32d2bb --- /dev/null +++ b/sui-bridge-contracts/sui/sources/registry.move @@ -0,0 +1,183 @@ +/// Chain registry + group-key registry + governance/guardian capabilities. +/// +/// The registry is the generic seam (bridge-spec.md §7): `internal_id ⇄ +/// {native id, family, outbox, inbox, finality}`. A third chain plugs in here +/// and in the per-family verify adapter (see `envelope`), nowhere else. +/// +/// Two capabilities, mirroring the spec's separation of duties: +/// - `GuardianCap` — pause/unpause Outbox + Inbox (circuit breaker, §2.7). +/// - `GovernanceCap` — edit the registry, register group keys, set threshold. +module sui_bridge::registry; + +use sui::table::{Self, Table}; +use sui_bridge::chain_id; +use sui_bridge::errors; +use sui_bridge::events; + +/// Pause/unpause authority (held by a guardian multisig in production). +public struct GuardianCap has key, store { id: UID } + +/// Registry + key-rotation authority (held by governance). +public struct GovernanceCap has key, store { id: UID } + +public struct ChainEntry has store, drop { + /// Authoritative native id (full EVM chainId, Sui chain identifier, …). + /// The 5-bit family lives in the registry key, recovered via `chain_id`. + native_identifier: vector, + outbox_addr: vector, + inbox_addr: vector, + /// 0 = EVM confirmation depth, 1 = Sui finalized-checkpoint rule (§4). + finality_kind: u8, + finality_value: u64, +} + +public struct ChainRegistry has key { + id: UID, + chains: Table, +} + +public struct GroupKey has store, drop { + scheme_tag: u8, + pubkey: vector, +} + +/// Registered group public keys, looked up by `group_pubkey_id` from the +/// delivered envelope so keys can rotate without an ABI change. +public struct GroupKeyRegistry has key { + id: UID, + keys: Table, + /// Signer threshold (k-of-n) — governance metadata. Not verified on-chain: + /// a single aggregated threshold signature simply verifies against the + /// group pubkey. Surfaced for transparency/rotation tooling. + threshold_k: u16, + threshold_n: u16, +} + +fun init(ctx: &mut TxContext) { + transfer::share_object(ChainRegistry { + id: object::new(ctx), + chains: table::new(ctx), + }); + transfer::share_object(GroupKeyRegistry { + id: object::new(ctx), + keys: table::new(ctx), + threshold_k: 1, + threshold_n: 1, + }); + transfer::public_transfer(GuardianCap { id: object::new(ctx) }, ctx.sender()); + transfer::public_transfer(GovernanceCap { id: object::new(ctx) }, ctx.sender()); +} + +// --- chain registry (governance) --- + +/// Register a chain. The family is derived from `internal_id`'s top bits (see +/// `chain_id`) and validated; it is not passed or stored separately. +public fun register_chain( + _: &GovernanceCap, + registry: &mut ChainRegistry, + internal_id: u32, + native_identifier: vector, + outbox_addr: vector, + inbox_addr: vector, + finality_kind: u8, + finality_value: u64, +) { + assert!(!registry.chains.contains(internal_id), errors::chain_already_registered()); + let fam = chain_id::family(internal_id); + assert!(chain_id::is_valid_family(fam), errors::unknown_family()); + registry.chains.add(internal_id, ChainEntry { + native_identifier, + outbox_addr, + inbox_addr, + finality_kind, + finality_value, + }); + events::emit_chain_registered(internal_id, fam); +} + +/// Update the mutable fields of an already-registered chain: the peer Outbox/ +/// Inbox addresses and the finality rule. The internal id, family, and +/// native_identifier are the chain's immutable identity and are left untouched. +/// Governance-gated. Use this to backfill a peer chain's contract addresses once +/// that chain has been deployed (spec §7). +public fun update_chain( + _: &GovernanceCap, + registry: &mut ChainRegistry, + internal_id: u32, + outbox_addr: vector, + inbox_addr: vector, + finality_kind: u8, + finality_value: u64, +) { + assert!(registry.chains.contains(internal_id), errors::chain_not_registered()); + let e = registry.chains.borrow_mut(internal_id); + e.outbox_addr = outbox_addr; + e.inbox_addr = inbox_addr; + e.finality_kind = finality_kind; + e.finality_value = finality_value; + events::emit_chain_registered(internal_id, chain_id::family(internal_id)); +} + +public fun is_registered(registry: &ChainRegistry, internal_id: u32): bool { + registry.chains.contains(internal_id) +} + +/// The registered peer `(outbox_addr, inbox_addr)` for a chain. Aborts if the +/// chain is not registered. +public fun chain_endpoints(registry: &ChainRegistry, internal_id: u32): (vector, vector) { + assert!(registry.chains.contains(internal_id), errors::chain_not_registered()); + let e = registry.chains.borrow(internal_id); + (e.outbox_addr, e.inbox_addr) +} + +public fun family(registry: &ChainRegistry, internal_id: u32): u8 { + assert!(registry.chains.contains(internal_id), errors::chain_not_registered()); + chain_id::family(internal_id) +} + +public fun finality(registry: &ChainRegistry, internal_id: u32): (u8, u64) { + assert!(registry.chains.contains(internal_id), errors::chain_not_registered()); + let e = registry.chains.borrow(internal_id); + (e.finality_kind, e.finality_value) +} + +// --- group-key registry (governance / rotation) --- + +public fun register_group_key( + _: &GovernanceCap, + keys: &mut GroupKeyRegistry, + group_pubkey_id: u32, + scheme_tag: u8, + pubkey: vector, +) { + assert!(!keys.keys.contains(group_pubkey_id), errors::group_key_already_registered()); + keys.keys.add(group_pubkey_id, GroupKey { scheme_tag, pubkey }); + events::emit_group_key_registered(group_pubkey_id, scheme_tag); +} + +public fun set_signer_threshold(_: &GovernanceCap, keys: &mut GroupKeyRegistry, k: u16, n: u16) { + assert!(k >= 1 && k <= n, errors::invalid_threshold()); + keys.threshold_k = k; + keys.threshold_n = n; +} + +public fun has_group_key(keys: &GroupKeyRegistry, group_pubkey_id: u32): bool { + keys.keys.contains(group_pubkey_id) +} + +/// Returns `(scheme_tag, pubkey)` for a registered group key, aborting if the +/// id is unknown. Used by the Inbox verify path. +public fun group_key(keys: &GroupKeyRegistry, group_pubkey_id: u32): (u8, vector) { + assert!(keys.keys.contains(group_pubkey_id), errors::group_key_not_registered()); + let gk = keys.keys.borrow(group_pubkey_id); + (gk.scheme_tag, gk.pubkey) +} + +public fun threshold(keys: &GroupKeyRegistry): (u16, u16) { + (keys.threshold_k, keys.threshold_n) +} + +#[test_only] +public fun init_for_testing(ctx: &mut TxContext) { + init(ctx); +} diff --git a/sui-bridge-contracts/sui/tests/message_tests.move b/sui-bridge-contracts/sui/tests/message_tests.move new file mode 100644 index 00000000..9e00e188 --- /dev/null +++ b/sui-bridge-contracts/sui/tests/message_tests.move @@ -0,0 +1,80 @@ +#[test_only] +module sui_bridge::message_tests; + +use sui_bridge::chain_id; +use sui_bridge::message; + +fun filled(b: u8, n: u64): vector { + let mut v = vector[]; + let mut i = 0; + while (i < n) { v.push_back(b); i = i + 1; }; + v +} + +#[test] +fun encode_length_is_fixed_header_plus_payload() { + let payload = b"hello-bridge"; + let m = message::new(2, 1, 7, filled(0xab, 32), filled(0xcd, 32), payload); + let enc = message::encode(&m); + // 1 (version) + 4 + 4 + 8 (ints) + 32 + 32 (apps) + 4 (len) + payload. + assert!(enc.length() == 1 + 4 + 4 + 8 + 32 + 32 + 4 + payload.length(), 0); +} + +#[test] +fun hash_is_deterministic_and_field_sensitive() { + let ds = message::derive_domain_sep(filled(0x01, 32)); + let a = message::new(2, 1, 7, filled(0xab, 32), filled(0xcd, 32), b"x"); + let b = message::new(2, 1, 7, filled(0xab, 32), filled(0xcd, 32), b"x"); + assert!(message::hash(&a, ds) == message::hash(&b, ds), 0); + + // Any field change perturbs the digest. + let diff_nonce = message::new(2, 1, 8, filled(0xab, 32), filled(0xcd, 32), b"x"); + let diff_payload = message::new(2, 1, 7, filled(0xab, 32), filled(0xcd, 32), b"y"); + assert!(message::hash(&a, ds) != message::hash(&diff_nonce, ds), 1); + assert!(message::hash(&a, ds) != message::hash(&diff_payload, ds), 2); + + // A different deployment salt perturbs the digest (domain separation). + let ds2 = message::derive_domain_sep(filled(0x02, 32)); + assert!(message::hash(&a, ds) != message::hash(&a, ds2), 3); +} + +/// Cross-checks the Move keccak256 + canonical encoding against an independent +/// offline implementation (@noble/hashes keccak_256 over the same big-endian +/// packed layout). If the encoding ever drifts, this digest stops matching and +/// signatures produced off-chain would fail on-chain. +#[test] +fun known_digest_vector() { + // src = HyperEVM (family 2, chainId 998), dst = Sui (family 1, local 0). + let src = chain_id::new(chain_id::family_evm(), 998); + let dst = chain_id::new(chain_id::family_sui(), 0); + let m = message::new(src, dst, 7, filled(0xab, 32), filled(0xcd, 32), b"hello-bridge"); + // Domain-separated under the shared TEST_SALT = 0x01*32 (see bridge-types + // message.rs and the cross-language vectors from `group_keys` example). + let ds = message::derive_domain_sep(filled(0x01, 32)); + let expected = x"535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707"; + assert!(message::hash(&m, ds) == expected, 0); +} + +#[test] +#[expected_failure] +fun rejects_non_bytes32_src_app() { + let _ = message::new(2, 1, 7, filled(0xab, 31), filled(0xcd, 32), b"x"); +} + +/// The BCS bytes a relayer passes to `bridge_receive` (produced by Rust +/// `CrossChainMessage::to_move_bcs`) decode back to the known-vector message and +/// hash to the same domain-separated digest. Regenerate via the `group_keys` +/// example. +#[test] +fun from_bcs_decodes_to_known_digest() { + let bcs_bytes = + x"01e603001000000008070000000000000020abababababababababababababababababababababababababababababababab20cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd0c68656c6c6f2d627269646765"; + let m = message::from_bcs(bcs_bytes); + assert!(message::src_chain_id(&m) == chain_id::new(chain_id::family_evm(), 998), 0); + assert!(message::dst_chain_id(&m) == chain_id::new(chain_id::family_sui(), 0), 1); + assert!(message::nonce(&m) == 7, 2); + assert!(message::payload(&m) == b"hello-bridge", 3); + let ds = message::derive_domain_sep(filled(0x01, 32)); + let expected = x"535392536947463d04988702a5480f431f34efed3cf557dc12aa434c2decd707"; + assert!(message::hash(&m, ds) == expected, 4); +} diff --git a/sui-bridge-contracts/sui/tests/messaging_tests.move b/sui-bridge-contracts/sui/tests/messaging_tests.move new file mode 100644 index 00000000..7782c814 --- /dev/null +++ b/sui-bridge-contracts/sui/tests/messaging_tests.move @@ -0,0 +1,349 @@ +#[test_only] +module sui_bridge::messaging_tests; + +use sui::test_scenario::{Self as ts, Scenario}; +use sui_bridge::chain_id; +use sui_bridge::envelope; +use sui_bridge::inbox::{Self, Inbox}; +use sui_bridge::message; +use sui_bridge::outbox::{Self, Outbox}; +use sui_bridge::registry::{Self, ChainRegistry, GroupKeyRegistry, GovernanceCap, GuardianCap}; + +const ADMIN: address = @0xA; + +// Internal chain ids = (family << 27) | local (see `chain_id`). +// Sui = (1 << 27) | 0 = 134217728 +// Hyper = (2 << 27) | 998 = 268436454 +const SUI_ID: u32 = 134217728; +const HYPER_ID: u32 = 268436454; + +/// Shared per-deployment test salt (matches bridge-types TEST_SALT = 0x01*32). +fun test_salt(): vector { filled(0x01, 32) } + +/// A stand-in for a Layer 2 app (e.g. a Locker). Its object id is the 32-byte +/// `dst_app` / `src_app` identity used by `consume` / `send`. +public struct TestApp has key { id: UID } + +fun filled(b: u8, n: u64): vector { + let mut v = vector[]; + let mut i = 0; + while (i < n) { v.push_back(b); i = i + 1; }; + v +} + +// Ed25519 group key + a valid signature over the DOMAIN-SEPARATED digest (salt +// = 0x01*32) of the message {src=2, dst=1, nonce=7, src_app=0xab*32, +// dst_app=0xcd*32, payload="hello-bridge"} — regenerate via +// `cargo run -p bridge-signer --example group_keys`. +fun group_pubkey(): vector { + x"2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12" +} +fun valid_signature(): vector { + x"12bc85a949906a86bdea305aa6bc32ef704e77de62ea5fb65a3df3a39902e53398ca95da28a3c34aa8187edcf8f6936330c94016e1e1c4d3f2f7b80027190001" +} + +/// Tx 1: publish/init. Tx 2: register both chains + the Ed25519 group key, and +/// create the Inbox (dst=Sui) + Outbox (src=Sui). Leaves the scenario at the +/// start of tx 3 with everything shared. +fun setup(s: &mut Scenario) { + registry::init_for_testing(s.ctx()); + s.next_tx(ADMIN); + { + let gov = s.take_from_sender(); + let guardian = s.take_from_sender(); + let mut chains = s.take_shared(); + let mut keys = s.take_shared(); + + registry::register_chain( + &gov, &mut chains, SUI_ID, b"sui-testnet", + filled(0x01, 32), filled(0x02, 32), 1, 0, + ); + registry::register_chain( + &gov, &mut chains, HYPER_ID, b"hyperevm-testnet", + filled(0x03, 32), filled(0x04, 32), 0, 12, + ); + registry::register_group_key(&gov, &mut keys, 1, envelope::scheme_ed25519(), group_pubkey()); + + inbox::create(&gov, SUI_ID, test_salt(), s.ctx()); + outbox::create(&gov, SUI_ID, test_salt(), s.ctx()); + + ts::return_shared(chains); + ts::return_shared(keys); + s.return_to_sender(gov); + s.return_to_sender(guardian); + }; + s.next_tx(ADMIN); +} + +/// The exact message the offline vector signed. +fun vector_message(): message::CrossChainMessage { + message::new(HYPER_ID, SUI_ID, 7, filled(0xab, 32), filled(0xcd, 32), b"hello-bridge") +} + +#[test] +fun chain_id_encoding_round_trips() { + // The hardcoded test constants match the (family << 27 | local) scheme. + assert!(SUI_ID == chain_id::new(chain_id::family_sui(), 0), 0); + assert!(HYPER_ID == chain_id::new(chain_id::family_evm(), 998), 1); + // Family + local are recoverable by shift/mask, no registry needed. + assert!(chain_id::family(HYPER_ID) == chain_id::family_evm(), 2); + assert!(chain_id::local(HYPER_ID) == 998, 3); + assert!(chain_id::family(SUI_ID) == chain_id::family_sui(), 4); + assert!(chain_id::local(SUI_ID) == 0, 5); +} + +#[test] +fun receive_accepts_valid_threshold_signature() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let inbox = s.take_shared(); + let keys = s.take_shared(); + + let env = envelope::new(envelope::scheme_ed25519(), 1, valid_signature()); + let delivered = inbox::receive(&inbox, &keys, vector_message(), env); + + assert!(inbox::delivered_src_chain_id(&delivered) == HYPER_ID, 0); + assert!(inbox::delivered_payload(&delivered) == b"hello-bridge", 1); + inbox::destroy_delivered_for_testing(delivered); + + ts::return_shared(inbox); + ts::return_shared(keys); + s.end(); +} + +#[test] +#[expected_failure] +fun receive_rejects_tampered_signature() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let inbox = s.take_shared(); + let keys = s.take_shared(); + + let mut bad = valid_signature(); + *bad.borrow_mut(0) = *bad.borrow(0) ^ 0xff; // flip a byte + let env = envelope::new(envelope::scheme_ed25519(), 1, bad); + let delivered = inbox::receive(&inbox, &keys, vector_message(), env); + + inbox::destroy_delivered_for_testing(delivered); // unreachable + ts::return_shared(inbox); + ts::return_shared(keys); + s.end(); +} + +#[test] +#[expected_failure] +fun receive_rejects_wrong_dst_chain() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let inbox = s.take_shared(); + let keys = s.take_shared(); + + // dst = HyperEVM, but this Inbox is the Sui inbox → wrong_dst_chain (checked + // before signature, so the signature need not be valid). + let wrong = message::new(SUI_ID, HYPER_ID, 1, filled(0xab, 32), filled(0xcd, 32), b"x"); + let env = envelope::new(envelope::scheme_ed25519(), 1, valid_signature()); + let delivered = inbox::receive(&inbox, &keys, wrong, env); + + inbox::destroy_delivered_for_testing(delivered); // unreachable + ts::return_shared(inbox); + ts::return_shared(keys); + s.end(); +} + +#[test] +fun consume_delivers_and_marks_replay() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let mut inbox = s.take_shared(); + + let app = TestApp { id: object::new(s.ctx()) }; + let dst_app = object::uid_to_bytes(&app.id); + let ds = message::derive_domain_sep(test_salt()); + let m = message::new(HYPER_ID, SUI_ID, 9, filled(0xab, 32), dst_app, b"payload-bytes"); + let hash = message::hash(&m, ds); + + let delivered = inbox::deliver_for_testing(&m, ds); + let (src_chain, src_app, payload) = inbox::consume(&mut inbox, delivered, &app.id); + + assert!(src_chain == HYPER_ID, 0); + assert!(src_app == filled(0xab, 32), 1); + assert!(payload == b"payload-bytes", 2); + assert!(inbox::is_consumed(&inbox, hash), 3); + + let TestApp { id } = app; + id.delete(); + ts::return_shared(inbox); + s.end(); +} + +#[test] +#[expected_failure] +fun consume_rejects_replay() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let mut inbox = s.take_shared(); + + let app = TestApp { id: object::new(s.ctx()) }; + let dst_app = object::uid_to_bytes(&app.id); + let m = message::new(HYPER_ID, SUI_ID, 9, filled(0xab, 32), dst_app, b"p"); + let ds = message::derive_domain_sep(test_salt()); + + inbox::consume(&mut inbox, inbox::deliver_for_testing(&m, ds), &app.id); + // Same message hash a second time → message_already_consumed. + inbox::consume(&mut inbox, inbox::deliver_for_testing(&m, ds), &app.id); + + let TestApp { id } = app; + id.delete(); + ts::return_shared(inbox); + s.end(); +} + +#[test] +#[expected_failure] +fun consume_rejects_wrong_app_identity() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let mut inbox = s.take_shared(); + + let app = TestApp { id: object::new(s.ctx()) }; + // dst_app deliberately not the app's id. + let m = message::new(HYPER_ID, SUI_ID, 9, filled(0xab, 32), filled(0xee, 32), b"p"); + let ds = message::derive_domain_sep(test_salt()); + inbox::consume(&mut inbox, inbox::deliver_for_testing(&m, ds), &app.id); + + let TestApp { id } = app; + id.delete(); + ts::return_shared(inbox); + s.end(); +} + +#[test] +fun outbox_send_assigns_monotonic_nonce_and_matching_hash() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let mut outbox = s.take_shared(); + + let app = TestApp { id: object::new(s.ctx()) }; + let dst_app = filled(0xcd, 32); + + let (n0, h0) = outbox::send(&mut outbox, &app.id, HYPER_ID, dst_app, b"first"); + let (n1, _h1) = outbox::send(&mut outbox, &app.id, HYPER_ID, dst_app, b"second"); + assert!(n0 == 0 && n1 == 1, 0); + + // The emitted hash equals an independent recompute of the same message + // under the outbox's deployment salt. + let ds = message::derive_domain_sep(test_salt()); + let expected = message::new(SUI_ID, HYPER_ID, 0, object::uid_to_bytes(&app.id), dst_app, b"first"); + assert!(h0 == message::hash(&expected, ds), 1); + + assert!(outbox::next_nonce(&mut outbox, HYPER_ID) == 2, 2); + + let TestApp { id } = app; + id.delete(); + ts::return_shared(outbox); + s.end(); +} + +#[test] +#[expected_failure] +fun outbox_send_blocked_when_paused() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let guardian = s.take_from_sender(); + let mut outbox = s.take_shared(); + + outbox::set_paused(&guardian, &mut outbox, true); + let app = TestApp { id: object::new(s.ctx()) }; + outbox::send(&mut outbox, &app.id, HYPER_ID, filled(0xcd, 32), b"x"); + + let TestApp { id } = app; + id.delete(); + s.return_to_sender(guardian); + ts::return_shared(outbox); + s.end(); +} + +/// End-to-end BCS parity: the exact `vector` args a generic relayer passes +/// (Rust `to_move_bcs`) decode via `from_bcs` and verify through the real +/// `inbox::receive` — the same domain-separated Ed25519 signature the relayer +/// obtains from the signer. This is the byte-level contract the Sui submitter +/// relies on (relayer-dispatch-design §3.1). +#[test] +fun receive_accepts_bcs_decoded_relayer_args() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let inbox = s.take_shared(); + let keys = s.take_shared(); + + let m = message::from_bcs( + x"01e603001000000008070000000000000020abababababababababababababababababababababababababababababababab20cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd0c68656c6c6f2d627269646765", + ); + let env = envelope::from_bcs( + x"00010000004012bc85a949906a86bdea305aa6bc32ef704e77de62ea5fb65a3df3a39902e53398ca95da28a3c34aa8187edcf8f6936330c94016e1e1c4d3f2f7b80027190001", + ); + let delivered = inbox::receive(&inbox, &keys, m, env); + assert!(inbox::delivered_payload(&delivered) == b"hello-bridge", 0); + inbox::destroy_delivered_for_testing(delivered); + + ts::return_shared(inbox); + ts::return_shared(keys); + s.end(); +} + +#[test] +fun update_chain_backfills_peer_addresses() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let gov = s.take_from_sender(); + let mut chains = s.take_shared(); + + // HYPER_ID was registered in setup with placeholder endpoints; backfill real ones. + let real_outbox = filled(0xaa, 32); + let real_inbox = filled(0xbb, 32); + registry::update_chain(&gov, &mut chains, HYPER_ID, real_outbox, real_inbox, 0, 20); + + let (o, i) = registry::chain_endpoints(&chains, HYPER_ID); + assert!(o == real_outbox, 0); + assert!(i == real_inbox, 1); + let (fk, fv) = registry::finality(&chains, HYPER_ID); + assert!(fk == 0 && fv == 20, 2); + + s.return_to_sender(gov); + ts::return_shared(chains); + s.end(); +} + +#[test] +#[expected_failure] +fun update_chain_unregistered_aborts() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let gov = s.take_from_sender(); + let mut chains = s.take_shared(); + + // internal id 999... never registered. + registry::update_chain(&gov, &mut chains, 201326592, filled(0xaa, 32), filled(0xbb, 32), 0, 1); + + s.return_to_sender(gov); + ts::return_shared(chains); + s.end(); +} + +#[test] +#[expected_failure] +fun duplicate_chain_registration_aborts() { + let mut s = ts::begin(ADMIN); + setup(&mut s); + let gov = s.take_from_sender(); + let mut chains = s.take_shared(); + + // SUI_ID already registered in setup. + registry::register_chain( + &gov, &mut chains, SUI_ID, b"dup", + filled(0x01, 32), filled(0x02, 32), 1, 0, + ); + + s.return_to_sender(gov); + ts::return_shared(chains); + s.end(); +}