diff --git a/.github/workflows/boston-dynamics-atlas-tier1.yml b/.github/workflows/boston-dynamics-atlas-tier1.yml new file mode 100644 index 000000000..0e6bb8c45 --- /dev/null +++ b/.github/workflows/boston-dynamics-atlas-tier1.yml @@ -0,0 +1,296 @@ +name: Boston Dynamics Atlas Tier 1 + +on: + pull_request: + branches: [main, boston-dynamics-atlas-tier-1] + paths: + - 'bridge/boston_dynamics/atlas_bridge/**' + - 'bridge/common/zenoh_bridge/**' + - 'registry/vendors/boston-dynamics/atlas/**' + - '.github/workflows/boston-dynamics-atlas-tier1.yml' + push: + branches: [main, boston-dynamics-atlas-tier-1] + workflow_dispatch: + +concurrency: + group: atlas-tier1-${{ github.ref }} + cancel-in-progress: true + +env: + BRIDGE: bridge/boston_dynamics/atlas_bridge + +jobs: + tests: + name: Unit and contract tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install dependencies + run: pip install -r $BRIDGE/requirements.txt + - name: Fetch the pinned Atlas v4 description + run: python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model + - name: Run the test suite + run: python -m pytest $BRIDGE/tests -v + + task: + name: Inspection episode must succeed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install dependencies + run: pip install -r $BRIDGE/requirements.txt + - name: Fetch the pinned Atlas v4 description + run: python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model + - name: MuJoCo episode + # The runner exits non-zero unless every target was reached, the robot + # stayed standing and nothing touched the shelf. + run: | + mkdir -p artifacts + python -m bridge.boston_dynamics.atlas_bridge.runner \ + --json-output artifacts/mujoco-inspection-episode.json + - name: PyBullet episode + run: | + python -m bridge.boston_dynamics.atlas_bridge.pybullet_runner \ + --json-output artifacts/pybullet-inspection-episode.json + - name: Webots world generation + # Webots itself is not on GitHub's runners, so CI verifies that the + # PROTO and the world still generate from the pinned URDF. The Webots + # episode is run locally and its result is committed under docs/evidence. + run: | + python -m bridge.boston_dynamics.atlas_bridge.webots_env --setup-only + - name: Sim-to-sim comparison + # Reports Webots as unavailable here and scores the engines that ran; + # a missing engine never turns a failing comparison into a passing one. + run: | + python -m bridge.boston_dynamics.atlas_bridge.sim2sim \ + --json-output artifacts/sim2sim-validation.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-simulation-evidence + path: artifacts/ + + tunnel: + name: Go tunnel builds and its payment contract holds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: tunnel/go.sum + # zenoh-go is a cgo binding, so the tunnel cannot be built without the + # native library. Fetching it here is what makes "the tunnel builds from a + # clean checkout" a check rather than a claim. + - name: Fetch zenoh-c + run: | + curl -sSLO https://github.com/eclipse-zenoh/zenoh-c/releases/download/1.9.0/zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip + unzip -q zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip -d zenohc + echo "ZENOHC=$PWD/zenohc" >> $GITHUB_ENV + - name: Build the tunnel + working-directory: tunnel + env: + CGO_ENABLED: "1" + run: | + export CGO_CFLAGS="-I$ZENOHC/include" + export CGO_LDFLAGS="-L$ZENOHC/lib -lzenohc" + go build -o "$RUNNER_TEMP/tunnel" ./cmd + ls -la "$RUNNER_TEMP/tunnel" + # The payment guarantee the bounty turns on, held without a wallet or a + # chain: a 202 says nothing about the outcome, success settles once, + # failure and timeout settle zero times, and a request that cannot be + # correlated never reaches Zenoh. + - name: Payment contract tests + working-directory: tunnel + env: + CGO_ENABLED: "1" + run: | + export CGO_CFLAGS="-I$ZENOHC/include" + export CGO_LDFLAGS="-L$ZENOHC/lib -lzenohc" + export LD_LIBRARY_PATH="$ZENOHC/lib:$LD_LIBRARY_PATH" + go vet ./... + go test ./... -v + - name: Formatting + working-directory: tunnel + run: | + unformatted=$(gofmt -l cmd internal config) + if [ -n "$unformatted" ]; then echo "$unformatted"; exit 1; fi + + payment: + name: x402 payment gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install dependencies + run: pip install -r $BRIDGE/requirements.txt + - name: Fetch the pinned Atlas v4 description + run: python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model + - name: Payment-safety tests + run: python -m pytest $BRIDGE/tests/test_x402_payment_safety.py -v + - name: Durable idempotency tests + run: python -m pytest $BRIDGE/tests/test_idempotency.py -v + - name: Live x402 facilitator must refuse a forged authorization + # Drives https://x402.org/facilitator. Protocol checks alone accept the + # payload; only the facilitator can tell it was never signed. + run: python -m pytest $BRIDGE/tests/test_facilitator.py -v + - name: End-to-end paid-action demo (in-process) + run: | + mkdir -p artifacts + python -m bridge.boston_dynamics.atlas_bridge.demo_e2e \n --json-output artifacts/demo-e2e-evidence.json | tee artifacts/demo-e2e.txt + - name: End-to-end paid action over the real Zenoh transport + # Publishes on robot/tunnel/action, the bridge executes the MuJoCo + # episode, and the result is correlated back by action_id. Exits + # non-zero unless every payment invariant holds. + run: | + python -m bridge.boston_dynamics.atlas_bridge.demo_tunnel \n --json-output artifacts/tunnel-e2e-evidence.json | tee artifacts/demo-tunnel.txt + - name: Re-verify the on-chain settlement for the paid action + # Reads the settlement named by real-paid-run.json back from a public + # Base Sepolia RPC. Exits non-zero if it is missing, reverted, carries no + # USDC transfer, or its AuthorizationUsed nonce is not keccak256 of the + # action id it claims to have paid for. + run: | + python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence \ + --json-output artifacts/onchain-settlement.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-x402-evidence + path: | + artifacts/demo-e2e.txt + artifacts/demo-tunnel.txt + artifacts/onchain-settlement.json + artifacts/demo-e2e-evidence.json + artifacts/tunnel-e2e-evidence.json + + secrets: + name: No secrets in the diff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Reject key material and wallet files + run: | + if git log --diff-filter=A --name-only --pretty=format: origin/main..HEAD \ + | grep -Ei '(^|/)(wallet|secret|credentials)\.json$|\.pem$|id_rsa'; then + echo "::error::A key or wallet file was added on this branch." + exit 1 + fi + if git grep -nIE '"private_key"\s*:\s*"[0-9a-fA-F]{64}"' -- . ; then + echo "::error::A raw private key is present in the tree." + exit 1 + fi + echo "No key material found." + + live: + name: One real paid action, executed in this run + # Secrets do not exist in a workflow triggered by a pull request from a + # fork, so this job runs where they do: a push to this branch, or a manual + # dispatch. On a pull request it is skipped rather than failed — a job that + # cannot pay should not pretend the payment failed. + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + # Nothing above should be trusted with money until the payment contract + # itself has held on this commit. + needs: [tunnel, payment] + # A settlement already in flight must never be cancelled half-way, so this + # job opts out of the workflow-level cancel-in-progress. + concurrency: + group: atlas-live-paid-${{ github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: tunnel/go.sum + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Fetch zenoh-c and build the tunnel this run will dial through + run: | + curl -sSLO https://github.com/eclipse-zenoh/zenoh-c/releases/download/1.9.0/zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip + unzip -q zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip -d zenohc + cd tunnel + CGO_ENABLED=1 CGO_CFLAGS="-I$PWD/../zenohc/include" CGO_LDFLAGS="-L$PWD/../zenohc/lib -lzenohc" go build -o "$RUNNER_TEMP/tunnel" ./cmd + echo "LD_LIBRARY_PATH=$PWD/../zenohc/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV + - name: Install dependencies and the pinned Atlas v4 description + run: | + pip install -r $BRIDGE/requirements.txt + python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model + # Reports only whether the secret is set. The value is never echoed, never + # interpolated into a command, and never written to a file. + - name: Are signing credentials available to this run? + id: creds + env: + SETTLEMENT_PRIVATE_KEY: ${{ secrets.SETTLEMENT_PRIVATE_KEY }} + SETTLEMENT_MNEMONIC: ${{ secrets.SETTLEMENT_MNEMONIC }} + run: | + if [ -n "$SETTLEMENT_PRIVATE_KEY" ] || [ -n "$SETTLEMENT_MNEMONIC" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "Signing credentials are present; the paid steps will run." + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "Neither SETTLEMENT_PRIVATE_KEY nor SETTLEMENT_MNEMONIC is set here." + echo "The rehearsal below still proves the path; nothing will be paid." + fi + # Everything except the signature: the tunnel dials the hosted relay, the + # robot is discovered, the price is read from the catalogue and the relay + # answers 402. Runs with or without credentials, so a broken harness is + # caught before money is involved rather than after. + - name: Rehearse the whole path, signing nothing + run: | + # `cmd | tee` reports tee's exit status, which is always 0. Without + # this, a run whose invariants fail is reported as a pass. + set -o pipefail + mkdir -p artifacts + python -m bridge.boston_dynamics.atlas_bridge.demo_fabric_e2e \ + --tunnel "$RUNNER_TEMP/tunnel" --dry-run \ + --json-output artifacts/live-dry-run.json \ + | tee artifacts/live-dry-run.txt + # The claim this job exists to make checkable: one action, paid for with a + # real EIP-3009 authorization whose nonce is keccak256(action_id), executed + # on Atlas in this run, settled only after the correlated result reports + # success. + - name: One paid action, settled only after the robot succeeds + if: steps.creds.outputs.available == 'true' + env: + SETTLEMENT_PRIVATE_KEY: ${{ secrets.SETTLEMENT_PRIVATE_KEY }} + SETTLEMENT_MNEMONIC: ${{ secrets.SETTLEMENT_MNEMONIC }} + run: | + set -o pipefail + python -m bridge.boston_dynamics.atlas_bridge.demo_fabric_e2e --tunnel "$RUNNER_TEMP/tunnel" --json-output artifacts/live-paid-run.json | tee artifacts/live-paid-run.txt + # The negative half, in the same trusted run: an action the catalogue + # refuses is answered, reported failed, and settles nothing — proven by + # asking USDC whether the authorization nonce was ever spent. + - name: A refused action must settle nothing + if: steps.creds.outputs.available == 'true' + env: + SETTLEMENT_PRIVATE_KEY: ${{ secrets.SETTLEMENT_PRIVATE_KEY }} + SETTLEMENT_MNEMONIC: ${{ secrets.SETTLEMENT_MNEMONIC }} + run: | + set -o pipefail + # 1s is outside the 5..60 the catalogue declares, so the bridge + # refuses the action rather than running it. --expect-failure only + # asserts the failure; this is what produces it. + python -m bridge.boston_dynamics.atlas_bridge.demo_fabric_e2e --tunnel "$RUNNER_TEMP/tunnel" --expect-failure --max-duration 1 --json-output artifacts/live-refused-run.json | tee artifacts/live-refused-run.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-live-paid-action + path: artifacts/live-* + retention-days: 90 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 15c39e757..5000a1119 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ log/ .colcon_settings.yaml PROJECT_MEMORY.md +# Bridge runtime state (durable idempotency records) +.robopay/ diff --git a/bridge/boston_dynamics/atlas_bridge/.gitignore b/bridge/boston_dynamics/atlas_bridge/.gitignore new file mode 100644 index 000000000..99e0aff47 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/.gitignore @@ -0,0 +1,4 @@ +models/atlas_v4/ +webots/protos/ +webots/worlds/ +webots/webots_inspection_result.json diff --git a/bridge/boston_dynamics/atlas_bridge/NOTICE.md b/bridge/boston_dynamics/atlas_bridge/NOTICE.md new file mode 100644 index 000000000..9254d2c00 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/NOTICE.md @@ -0,0 +1,23 @@ +# Third-party model attribution + +## Boston Dynamics Atlas v4 + +The Atlas v4 robot description used by this bridge is **not vendored in this +repository**. It is fetched at setup time from a pinned upstream commit, exactly +as recorded in [`models/model.lock.json`](models/model.lock.json): + +| Field | Value | +| --- | --- | +| Upstream | | +| Commit | `d32bcb2b35b94168b5ce27233ca62f3c8678886f` | +| Path | `roboschool/models_robot/atlas_description` | +| File | `urdf/atlas_v4_with_multisense.urdf` | +| License | MIT (`LICENSE.md` at the roboschool repository root) | + +Roboschool is distributed by OpenAI under the MIT License. The Atlas robot and +the Atlas name are property of Boston Dynamics; the description files are used +here only to simulate the robot, and no Boston Dynamics source or binary is +redistributed by this repository. + +Run `python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model` to +fetch the description into the local cache before running any simulator. diff --git a/bridge/boston_dynamics/atlas_bridge/README.md b/bridge/boston_dynamics/atlas_bridge/README.md new file mode 100644 index 000000000..bfe86b544 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/README.md @@ -0,0 +1,330 @@ +# Boston Dynamics Atlas — RoboPay simulator bridge + +Paid, policy-driven **shelf inspection** on a free-standing Boston Dynamics +Atlas v4, validated across MuJoCo, PyBullet and Webots R2025a. + +![Atlas shelf inspection](../../../docs/evidence/atlas-shelf-inspection.gif) + +## What it does + +A payment-gated `inspect_shelf` skill. Once x402 verification passes, a state +machine walks Atlas through three shelf points and only then does the action +settle: + +``` +STAND ──▶ REACH(t) ──▶ VERIFY(t) ──▶ … ──▶ RETURN ──▶ DONE + ▲ │ + └────────────┘ hold broken → re-converge +``` + +Each control tick re-reads the measured end-effector pose and the measured +joint configuration and solves a damped least-squares resolved-rate step. There +is no recorded trajectory anywhere in the bridge. + +## Measured results + +| Metric | MuJoCo | PyBullet | Webots | +| --- | --- | --- | --- | +| Targets reached and held | 3 / 3 | 3 / 3 | 3 / 3 | +| Mean end-effector error | 9.5 mm | 12.2 mm | 9.0 mm | +| Max end-effector error | 13.5 mm | 19.8 mm | 12.3 mm | +| Min pelvis height (fall threshold 0.70 m) | 0.908 m | 0.940 m | 0.898 m | +| Shelf collisions | 0 | 0 | 0 | +| Episode duration | 4.61 s | 4.98 s | 7.70 s | + +Raw output: [`docs/evidence/`](../../../docs/evidence/). Running any of the +commands below prints its result; none of them rewrites the committed evidence +unless you pass `--json-output`, so reproducing leaves the tree clean. MuJoCo runs are +bit-identical across repeats — see `test_run_is_repeatable`. + +## The robot + +Atlas v4 is **fetched, never vendored**. `models/model.lock.json` pins +[openai/roboschool](https://github.com/openai/roboschool) at +`d32bcb2` (MIT). Collision geometry upstream is analytic, so no mesh assets are +needed and none are committed. See [`NOTICE.md`](NOTICE.md). + +One URDF drives every engine: + +``` +atlas_v4_with_multisense.urdf + │ + ├── MuJoCo (URDF → MJCF at load time) + ├── PyBullet (loaded directly) + └── Webots (URDF → PROTO at setup time) +``` + +The Jacobian **and** the gravity feedforward are derived from that same URDF by +[`kinematics.py`](kinematics.py) rather than from each engine, so the controller +is literally identical everywhere. MuJoCo has `qfrc_bias` and PyBullet has +inverse dynamics, but Webots has neither — computing both terms from the model +keeps the three backends honestly the same controller. +`tests/test_kinematics.py` checks the Jacobian and the gravity model against +MuJoCo's independently computed ones. + +## Setup + +```bash +pip install -r bridge/boston_dynamics/atlas_bridge/requirements.txt +python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model +``` + +## Run + +```bash +python -m bridge.boston_dynamics.atlas_bridge.runner +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.pybullet_runner +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.sim2sim +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.demo_e2e +``` + +The same flow over the real Zenoh transport, gate to simulator to correlated +result (peer mode, no router needed): + +```bash +python -m bridge.boston_dynamics.atlas_bridge.demo_tunnel +``` + +The same path through the repository's own Go tunnel, with its real x402 +middleware making the payment decision — see +[`TUNNEL_BUILD.md`](TUNNEL_BUILD.md) to build it once: + +```bash +python -m bridge.boston_dynamics.atlas_bridge.demo_go_tunnel --tunnel /path/to/tunnel +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.reach_envelope +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.visual_evidence +``` + +Webots (requires a local Webots R2025a install, or `WEBOTS_EXE` set — the world +and the PROTO are generated from the same pinned URDF on the way in): + +```bash +python -m bridge.boston_dynamics.atlas_bridge.webots_env +``` + +Tests: + +```bash +python -m pytest bridge/boston_dynamics/atlas_bridge/tests -q +``` + +## Payment safety + +`x402.py` verifies the receipt (amount, asset, network, expiry, replay) and +`relay.py` gates execution behind it. `payment.py` holds the settlement ledger. +The invariant the tests pin down: + +This relay runs **in process and holds no wallet**, so its accepted case is +eligible for settlement rather than settled — the ledger enforces that +distinction, and `SETTLED` requires a transaction hash and its block: + +| Case | HTTP | Executed | Settlement | +| --- | --- | --- | --- | +| No payment | 402 | no | none | +| Wrong amount / asset / network | 400 | no | none | +| Valid payment, task succeeds | 200 | yes | eligible, **not on chain** | +| Valid payment, task fails or is stopped | 200 | yes | none | +| Replayed receipt | 409 | no | none | +| Forged authorization (facilitator) | 400 | no | none | +| Repeat of an idempotency key | — | no | none | + +Money actually moves on the two paid paths, and both answer differently because +they run through the tunnel rather than in process: + +| Path | HTTP | Settlement | +| --- | --- | --- | +| Hosted Fabric relay (`demo_fabric_e2e.py`) | **202** accepted, then async | after the result reports success | +| Direct facilitator (`real_paid_run.py`) | — | after the episode reports success | + +The settlement for the profile's paid action, on Base Sepolia: +[`0x2b3b71d0…c0f39`](https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39) +— **0.001 USDC**, the price the catalogue publishes, block 45706216, status +success, bound to `act-paid-de66513f791b` because its authorization nonce is +`keccak256(action_id)`. `settlement_evidence.py` reads that transaction out of +`real-paid-run.json` and re-checks it against a public RPC, failing if the +amount, asset, payer, payee, network or binding is not what the profile +declares. Testnet only, and no key material lives in this repository. + +## Operating the bridge + +### Requirements + +| | | +| --- | --- | +| Python | 3.12+ | +| MuJoCo | 3.11+ (installed by `requirements.txt`) | +| PyBullet | installed by `requirements.txt` | +| Webots | R2025a, installed separately — only needed for the Webots run | +| Zenoh | `eclipse-zenoh`, only needed for the tunnel. Peer mode works locally with no router; set `ZENOH_ENDPOINT` for a router or client deployment | + +### Configuration + +Every setting has a working default; none is required for the simulator runs. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `ROBOT_ID` | `atlas-sim-01` | Identity this bridge answers for; actions for other robots are ignored | +| `ZENOH_ENDPOINT` | *(peer mode)* | Router to connect to, e.g. `tcp/127.0.0.1:7447` | +| `ZENOH_CONFIG` | — | Path to a Zenoh config file; takes precedence over the endpoint | +| `ZENOH_ACTION_TOPIC` | `robot/tunnel/action` | Where payment-validated actions arrive | +| `ZENOH_RESULT_TOPIC` | `robot/tunnel/result` | Where correlated results are published | +| `ZENOH_METRICS_TOPIC` | `robot/boston_dynamics_atlas/metrics` | Simulator metrics stream | +| `ZENOH_READY_TOPIC` | `robot/boston_dynamics_atlas/ready` | Announced once on startup | +| `ATLAS_MJCF_DIR` | *(fetched cache)* | Override the Atlas description directory | +| `WEBOTS_EXE` | *(auto-discovered)* | Webots executable, if not on `PATH` | +| `WEB3_PROVIDER_URL` | — | RPC endpoint, only for executing a settlement | +| `SETTLEMENT_PRIVATE_KEY` | — | **Never commit this.** Only for executing a settlement | + +| `SETTLEMENT_MNEMONIC` | — | **Never commit this.** Recovery phrase, as an alternative to the key | +| `SETTLEMENT_ACCOUNT_INDEX` | `0` | Which account of that phrase to derive, `m/44'/60'/0'/0/` | + +### Wallet setup + +Only the two paid demos need a wallet; every simulator run, the whole test suite +and the payment-safety checks work without one. + +1. **Create a wallet you are willing to throw away.** Base Sepolia USDC has no + monetary value, and a key used for a demo should never be one that guards + anything. +2. **Fund it with test USDC** from the Coinbase Developer Platform faucet. You + need `0.001` per paid action. **No ETH is required** — under EIP-3009 the + payer only signs, and the facilitator submits the transaction and pays the + gas. +3. **Put the key in your own shell, not in a file.** Either form works: + + ```bash + export SETTLEMENT_PRIVATE_KEY=0x... # or + export SETTLEMENT_MNEMONIC="word word ..." + ``` + +4. **Set the payee** the robot is paid to, in the tunnel's `config.json` + (`evm_payee_address`). It is what the tunnel advertises in its `402` and where + the settlement lands; the two are the same value by construction, and + `TestTheAdvertisedPayeeIsTheConfiguredOne` holds that. +5. **Unset it when you are done**: `unset SETTLEMENT_PRIVATE_KEY`. + +### Testnet configuration + +| | | +| --- | --- | +| Network | Base Sepolia, `eip155:84532`, chain id 84532 | +| Asset | USDC `0x036CbD53842c5426634e7929541eC2318f3dCF7e`, 6 decimals | +| Price | `0.001` USDC — `1000` raw, as published in `skills.yaml` | +| Facilitator | `https://x402.org/facilitator` | +| RPC | `https://sepolia.base.org` | +| Explorer | `https://sepolia.basescan.org` | + +**Security.** The key is read from the environment and is never written to disk, +logged, echoed, or included in any evidence artefact — the artifacts record the +payer's *address*, which is public, and the signature, which the facilitator +needs anyway. Use a dedicated testnet wallet and treat it as disposable: a key +that has been pasted anywhere should be considered public for ever. This +repository contains no key material, and CI fails the build if any appears in +the diff. + +### Start the tunnel bridge + +```bash +zenohd +``` + +```bash +python -m bridge.boston_dynamics.atlas_bridge.bridge +``` + +On startup it announces itself on the ready topic with its profile id, robot id +and registered skills, then subscribes to the action topic. + +### Send an action + +The envelopes the profile ships are the ones the bridge accepts: + +```bash +zenoh put robot/tunnel/action --value "$(cat registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.inspect_shelf.json)" +``` + +**Expected success** on `robot/tunnel/result` — note that every correlation +field from the request is echoed back: + +```json +{ + "action_id": "act-atlas-inspect-0001", + "robot_id": "atlas-sim-01", + "skill_id": "inspect_shelf", + "params_hash": "sha256:…", + "idempotency_key": "idem-atlas-inspect-0001", + "status": "success", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "result": { "targets_completed": 3, "shelf_contacts": 0, "fall_detected": false } +} +``` + +**Expected failures**, all answered on the same topic and never settled: + +| Cause | `status` | `result.error_code` | +| --- | --- | --- | +| Skill not registered | `failure` | `UNREGISTERED_ACTION` | +| `action` and `skill_id` disagree | `failure` | `ACTION_SKILL_MISMATCH` | +| Undeclared or out-of-range parameter | `failure` | `INVALID_PARAMS` / `INVALID_DURATION` | +| Another episode already running | `failure` | `ROBOT_BUSY` | +| Simulator raised | `failure` | `SIMULATOR_EXECUTION_ERROR` | +| Malformed envelope | *(no reply)* | rejected before the simulator is touched | +| Action for another robot | *(no reply)* | ignored | + +**Safe stop** — interrupts a running episode; the interrupted inspection returns +`completion_reason: safe_stopped` with `success: false`, so it cannot settle: + +```bash +zenoh put robot/tunnel/action --value "$(cat registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.stop.json)" +``` + +### Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `Install eclipse-zenoh to run the Atlas bridge` | Transport extra missing | `pip install eclipse-zenoh` | +| Bridge starts but nothing arrives | Publisher and bridge are not on the same Zenoh network | Both in peer mode on one host needs no router; otherwise point both at the same `ZENOH_ENDPOINT` | +| No reply at all to an action | `robot_id` mismatch or malformed envelope | Check `ROBOT_ID`; both cases are logged | +| `Atlas download did not produce …` | Fetch blocked | Re-run `download_atlas_model`, check network | +| `Webots was not found` | Not installed or not on `PATH` | Install R2025a or set `WEBOTS_EXE` | +| Settlement command exits non-zero | RPC unreachable or tx not found | Retry; the check is read-only and safe | + +## Layout + +| Path | Role | +| --- | --- | +| `task.py` | Shelf geometry, targets, stance, thresholds — one source for all engines | +| `control_core.py` | State machine + damped least-squares resolved-rate IK | +| `model.py` | URDF → MJCF, actuators generated from URDF effort limits | +| `actuators.py` | Actuator addressing read from the compiled model, with drift checks | +| `episode.py` | Engine-agnostic episode loop and metric reporting | +| `mujoco_env.py`, `pybullet_env.py`, `webots_env.py` | Per-engine backends | +| `x402.py`, `relay.py`, `payment.py`, `settlement.py` | Payment gate and ledger | +| `facilitator.py` | Live x402 facilitator verification, failing closed | +| `idempotency.py` | Durable one-actuation-per-key store | +| `bridge.py` | Tunnel integration: action handler plus its Zenoh wiring | +| `demo_tunnel.py` | Paid action over the real Zenoh transport, end to end | +| `demo_go_tunnel.py` | The same path through the repository's own Go tunnel | +| `kinematics.py` | URDF-derived forward kinematics, Jacobian and gravity model | +| `reach_envelope.py` | Measures where Atlas can reach without losing balance | +| `settlement_evidence.py` | Re-reads the on-chain settlement from Base Sepolia | +| `visual_evidence.py` | Renders the annotated episode GIF | diff --git a/bridge/boston_dynamics/atlas_bridge/TUNNEL_BUILD.md b/bridge/boston_dynamics/atlas_bridge/TUNNEL_BUILD.md new file mode 100644 index 000000000..4e4a3f30c --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/TUNNEL_BUILD.md @@ -0,0 +1,101 @@ +# Building the Go tunnel for the end-to-end demo + +`demo_go_tunnel.py` drives the repository's own Go tunnel — the binary that +mounts the upstream x402 gin middleware and a real facilitator client — so the +payment decision is made by the tunnel rather than by any Python code. + +The tunnel is not part of this profile; these are the steps that produced the +binary used for the committed evidence, recorded so a reviewer can repeat them. + +## What the tunnel needs + +`tunnel/` imports `github.com/eclipse-zenoh/zenoh-go`, which is a cgo binding. +Building it therefore needs three things beyond the Go toolchain: + +| | | +| --- | --- | +| Go | 1.25+ | +| zenoh-c | matching native library, headers and import library | +| C toolchain | a **complete** one — see the note below | + +## Linux / macOS + +```bash +curl -LO https://github.com/eclipse-zenoh/zenoh-c/releases/download/1.9.0/zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip +unzip zenoh-c-1.9.0-x86_64-unknown-linux-gnu-standalone.zip -d zenohc +``` + +```bash +CGO_ENABLED=1 \ +CGO_CFLAGS="-I$PWD/zenohc/include" \ +CGO_LDFLAGS="-L$PWD/zenohc/lib -lzenohc" \ +go build -o tunnel ./tunnel/cmd +``` + +Put `zenohc/lib` on `LD_LIBRARY_PATH` (or `DYLD_LIBRARY_PATH`) when running it. + +## Windows + +```bash +curl -LO https://github.com/eclipse-zenoh/zenoh-c/releases/download/1.9.0/zenoh-c-1.9.0-x86_64-pc-windows-gnu-standalone.zip +``` + +Extract it, then build with a MinGW-w64 toolchain: + +```bash +CGO_ENABLED=1 \ +CC=/path/to/mingw64/bin/gcc.exe \ +CGO_CFLAGS="-I/path/to/zenohc/include" \ +CGO_LDFLAGS="-L/path/to/zenohc/lib -lzenohc" \ +go build -o tunnel.exe ./tunnel/cmd +``` + +Copy `zenohc/bin/zenohc.dll` next to `tunnel.exe` before running it. + +**Two Windows traps worth knowing**, both of which cost real time here: + +* Go's linker invokes whatever `CC` resolves to, **not** the first `gcc` on + `PATH`. If an older broken toolchain is installed, the build fails with + `cannot execute '…/collect2.exe'` even though a working `gcc` is earlier on + the path. Set `CC` explicitly. +* Some MinGW distributions ship `gcc.exe` without the matching + `libexec/gcc/**/collect2.exe`, so they can compile but not link. Verify with + `find -name collect2.exe` before assuming the toolchain is fine. + +## Running the demo + +```bash +python -m bridge.boston_dynamics.atlas_bridge.demo_go_tunnel --tunnel /path/to/tunnel +``` + +The demo starts the Atlas bridge, stands up a minimal WebSocket proxy in place +of the hosted Fabric backend, launches the tunnel against it, and sends an +unpaid action followed by a forged payment. It exits non-zero unless the tunnel +refuses both and the simulator is never reached. + +Tunnel configuration is read from `config.json` beside the binary: + +```json +{ + "robot_id": "atlas-sim-01", + "evm_payee_address": "0x", + "price": "$0.001", + "network": "eip155:84532" +} +``` + +## What this proves, and what it does not + +**Proves** — with the real tunnel and the real x402 middleware in the path: + +* an unpaid action is refused with `402` and payment requirements are advertised; +* a structurally valid but unsigned authorization is refused after the + middleware consults the live facilitator; +* neither request ever reaches Zenoh or the simulator. + +**Does not prove** — the accepting side, because this demo has no wallet and +stands in for the hosted relay with a local proxy. That half is proven elsewhere +and does move real USDC: `demo_fabric_e2e.py` runs the same tunnel against the +**hosted** Fabric relay with an operator-held wallet, and `real_paid_run.py` +takes the facilitator route directly. Both settle 0.001 USDC only after the +episode reports every target reached. diff --git a/bridge/boston_dynamics/atlas_bridge/__init__.py b/bridge/boston_dynamics/atlas_bridge/__init__.py new file mode 100644 index 000000000..3723be220 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/__init__.py @@ -0,0 +1 @@ +"""Boston Dynamics Atlas v4 RoboPay simulator bridge.""" diff --git a/bridge/boston_dynamics/atlas_bridge/actuators.py b/bridge/boston_dynamics/atlas_bridge/actuators.py new file mode 100644 index 000000000..56f4f4ec3 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/actuators.py @@ -0,0 +1,71 @@ +"""Actuator addressing derived from the loaded model, never hand-written. + +The previous revision of this bridge kept a hand-maintained ``ACTUATOR_ORDER`` +list. It silently disagreed with the compiled model, so 27 of 30 control +channels were cross-wired (knee commands reached the elbow). Everything here is +read back out of the compiled ``MjModel`` instead, and :func:`validate` turns any +future drift into an immediate, loud failure. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import mujoco +import numpy as np + + +@dataclass(frozen=True) +class ActuatorMap: + """Model-derived addressing for the actuated joints, in ``data.ctrl`` order.""" + + names: tuple[str, ...] + qpos_addresses: np.ndarray + qvel_addresses: np.ndarray + effort_limits: np.ndarray + + def index(self, joint: str) -> int: + return self.names.index(joint) + + def vector(self, values: dict[str, float], default: float = 0.0) -> np.ndarray: + """Build a ctrl-ordered vector from a ``{joint: value}`` mapping.""" + unknown = set(values) - set(self.names) + if unknown: + raise KeyError(f"Unknown Atlas joints: {sorted(unknown)}") + return np.array([values.get(name, default) for name in self.names], dtype=np.float64) + + def __len__(self) -> int: + return len(self.names) + + +def build(model: mujoco.MjModel) -> ActuatorMap: + """Read the actuator layout straight out of a compiled model.""" + joint_ids = model.actuator_trnid[:, 0] + names = tuple(model.joint(int(jid)).name for jid in joint_ids) + return ActuatorMap( + names=names, + qpos_addresses=model.jnt_qposadr[joint_ids].copy(), + qvel_addresses=model.jnt_dofadr[joint_ids].copy(), + effort_limits=np.abs(model.actuator_gear[:, 0]).astype(np.float64), + ) + + +def validate(model: mujoco.MjModel, expected_efforts: dict[str, float]) -> ActuatorMap: + """Build the map and assert it matches the upstream URDF joint efforts. + + Raises ``ValueError`` when the compiled model and the pinned URDF disagree on + which joints exist or on how strong they are. + """ + actuators = build(model) + missing = sorted(set(expected_efforts) - set(actuators.names)) + extra = sorted(set(actuators.names) - set(expected_efforts)) + if missing or extra: + raise ValueError( + f"Actuator set does not match the pinned URDF (missing={missing}, extra={extra})" + ) + for name, effort in zip(actuators.names, actuators.effort_limits): + if abs(effort - expected_efforts[name]) > 1e-6: + raise ValueError( + f"Effort limit drift on {name}: model={effort} urdf={expected_efforts[name]}" + ) + return actuators diff --git a/bridge/boston_dynamics/atlas_bridge/bridge.py b/bridge/boston_dynamics/atlas_bridge/bridge.py new file mode 100644 index 000000000..2a6012194 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/bridge.py @@ -0,0 +1,419 @@ +"""Zenoh bridge for paid Atlas shelf-inspection actions. + +Action envelopes arrive on ``robot/tunnel/action``, are validated against the +registered skill contract, executed on the simulator, and answered on +``robot/tunnel/result`` with the originating ``action_id`` so the tunnel can +correlate the asynchronous result with the paid request. + +The message handling lives in :class:`AtlasActionHandler`, which knows nothing +about transports. :class:`AtlasZenohBridge` only wires that handler to Zenoh. +The split exists so the full action path can be exercised by tests without a +live router — see ``tests/test_bridge_contract.py``. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import logging +import math +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .idempotency import ConflictingRequest, IdempotencyStore +from .runner import run_inspection +from .task import EPISODE_BUDGET_S + +LOGGER = logging.getLogger("robopay.atlas") +ACTION_TOPIC = "robot/tunnel/action" +RESULT_TOPIC = "robot/tunnel/result" +METRICS_TOPIC = "robot/boston_dynamics_atlas/metrics" +READY_TOPIC = "robot/boston_dynamics_atlas/ready" +ROBOT_ID = "atlas-sim-01" + +#: Skills this bridge will execute, matching the registered profile. +INSPECT_ACTION = "inspect_shelf" +STOP_ACTION = "stop" +ALLOWED_ACTIONS = {INSPECT_ACTION, STOP_ACTION} +#: Identity a payment-validated action must carry. Without these the tunnel cannot correlate +#: a result with the request that paid for it, and the action cannot be +#: deduplicated, so it is refused rather than executed on a guess. +REQUIRED_IDENTITY_FIELDS = ("action_id", "robot_id", "skill_id", "idempotency_key") +#: Parameters declared for ``inspect_shelf`` in ``skills.yaml``. +INSPECTION_PARAMS = {"maxDurationSec"} +MIN_DURATION_S = 5.0 +MAX_DURATION_S = 60.0 + +PROFILE_ID = "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" + + +def _payment_fingerprint(event) -> str: + """Stable digest of the payment an action arrived with.""" + payload = getattr(event, "payment_payload", None) + if not payload: + return "" + return hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode("utf-8") + ).hexdigest() + + +class ActionContractError(ValueError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def inspection_params(params: dict) -> float: + """Validate ``inspect_shelf`` parameters and return the duration budget.""" + if not isinstance(params, dict): + raise ActionContractError("INVALID_PARAMS", "params must be an object.") + unexpected = sorted(set(params) - INSPECTION_PARAMS) + if unexpected: + raise ActionContractError( + "INVALID_PARAMS", f"unregistered parameter(s): {', '.join(unexpected)}" + ) + raw_duration = params.get("maxDurationSec", EPISODE_BUDGET_S) + if isinstance(raw_duration, bool) or not isinstance(raw_duration, (int, float)): + raise ActionContractError( + "INVALID_DURATION", + f"maxDurationSec must be a number from {MIN_DURATION_S:g} to {MAX_DURATION_S:g}.", + ) + max_duration = float(raw_duration) + if not math.isfinite(max_duration) or not MIN_DURATION_S <= max_duration <= MAX_DURATION_S: + raise ActionContractError( + "INVALID_DURATION", + f"maxDurationSec must be between {MIN_DURATION_S:g} and {MAX_DURATION_S:g}.", + ) + return max_duration + + +def load_event_parser(): + """Load the shared tunnel ActionEvent parser from ``bridge/common``.""" + action_event_path = ( + Path(__file__).resolve().parents[2] + / "common" / "zenoh_bridge" / "zenoh_bridge" / "action_event.py" + ) + spec = importlib.util.spec_from_file_location("robopay_action_event", action_event_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load ActionEvent parser: {action_event_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.parse_action_event + + +class AtlasActionHandler: + """Transport-free action path: envelope in, correlated result out. + + ``publish`` receives the encoded result envelope. ``execute`` runs the skill + and defaults to the MuJoCo runner; tests substitute a fast stand-in. + """ + + def __init__( + self, + publish: Callable[[bytes], None], + robot_id: str = ROBOT_ID, + execute: Callable[..., dict] | None = None, + synchronous: bool = False, + idempotency: IdempotencyStore | None = None, + ) -> None: + self._publish_bytes = publish + self.robot_id = robot_id + self._execute = execute or run_inspection + self._synchronous = synchronous + self._idempotency = IdempotencyStore() if idempotency is None else idempotency + self._parse_action_event = load_event_parser() + self._stop_event = threading.Event() + self._stop_applied_event = threading.Event() + self._worker_lock = threading.Lock() + self._worker: threading.Thread | None = None + + # -- result publication ------------------------------------------------- + def _publish(self, event, status: str, result: dict) -> dict: + """Answer an action, echoing every field the tunnel correlates on.""" + envelope = { + "action_id": event.action_id, + "robot_id": event.robot_id, + "skill_id": event.skill_id, + "params_hash": event.params_hash, + "idempotency_key": event.idempotency_key, + "status": status, + "profile_id": PROFILE_ID, + "result": result, + } + self._publish_bytes(json.dumps(envelope).encode("utf-8")) + return envelope + + # -- skill execution ---------------------------------------------------- + def _run_inspection(self, event, max_duration: float) -> None: + status = "failure" + try: + result = self._execute( + max_duration_seconds=max_duration, + stop_requested=self._stop_event.is_set, + ) + except Exception as error: # noqa: BLE001 - reported, never swallowed + LOGGER.exception("Atlas simulator execution failed") + result = { + "error_code": "SIMULATOR_EXECUTION_ERROR", + "message": str(error), + "success": False, + } + if result.get("safe_stop_applied"): + self._stop_applied_event.set() + status = "safe_stopped" + elif result.get("success"): + status = "success" + self._idempotency.complete( + event.robot_id, event.skill_id, event.idempotency_key, status + ) + self._publish(event, "success" if result.get("success") else "failure", result) + + def _handle_stop(self, event) -> None: + if event.params: + self._publish(event, "failure", { + "error_code": "INVALID_PARAMS", + "message": "stop does not accept parameters", + "success": False, + }) + return + self._stop_event.set() + with self._worker_lock: + interrupted = self._worker is not None and self._worker.is_alive() + stop_confirmed = not interrupted or self._stop_applied_event.wait(timeout=5.0) + self._publish(event, "success" if stop_confirmed else "failure", { + "message": ( + "Safe stop applied" if stop_confirmed + else "Safe stop was not confirmed within 5 seconds" + ), + "error_code": None if stop_confirmed else "SAFE_STOP_TIMEOUT", + "safe_stop_applied": stop_confirmed, + "active_execution_interrupted": interrupted, + "success": stop_confirmed, + }) + + # -- entry point -------------------------------------------------------- + def handle(self, payload: bytes) -> str | None: + """Process one raw action envelope. Returns the outcome for tests.""" + event = self._parse_action_event(payload) + if event is None: + LOGGER.error("Rejected malformed ActionEvent before simulation.") + return "rejected_malformed" + if event.robot_id != self.robot_id: + LOGGER.debug("Ignoring ActionEvent for foreign robot %s", event.robot_id) + return "ignored_foreign_robot" + + missing = [name for name in REQUIRED_IDENTITY_FIELDS if not getattr(event, name, "")] + if missing: + self._publish(event, "failure", { + "error_code": "MISSING_IDENTITY", + "message": f"action envelope is missing {', '.join(missing)}", + "success": False, + }) + return "failure" + + action = event.action + if event.action != event.skill_id: + self._publish(event, "failure", {"error_code": "ACTION_SKILL_MISMATCH", "success": False}) + return "failure" + if action not in ALLOWED_ACTIONS: + self._publish(event, "failure", {"error_code": "UNREGISTERED_ACTION", "success": False}) + return "failure" + + if action == STOP_ACTION: + self._handle_stop(event) + return "stop" + + try: + max_duration = inspection_params(event.params) + except ActionContractError as error: + self._publish(event, "failure", { + "error_code": error.code, "message": str(error), "success": False, + }) + return "failure" + + # A payment-validated action actuates the robot once. The same key replays its + # recorded outcome; the same key describing a different request is a + # conflict, not a retry. + fingerprint = _payment_fingerprint(event) + try: + previous = self._idempotency.claim( + event.robot_id, event.skill_id, event.idempotency_key, + event.params_hash, fingerprint, event.action_id, + ) + except ConflictingRequest as conflict: + self._publish(event, "failure", { + "error_code": conflict.code, "message": str(conflict), "success": False, + }) + return "failure" + if previous is not None: + self._publish(event, "duplicate", { + "error_code": "DUPLICATE_ACTION", + "message": ( + f"idempotency key {event.idempotency_key!r} already actuated " + f"this robot as {previous.action_id}" + ), + "first_action_id": previous.action_id, + "first_status": previous.status, + "success": False, + }) + return "duplicate" + + with self._worker_lock: + if self._worker is not None and self._worker.is_alive(): + self._publish(event, "failure", {"error_code": "ROBOT_BUSY", "success": False}) + return "failure" + self._stop_event.clear() + self._stop_applied_event.clear() + if self._synchronous: + self._run_inspection(event, max_duration) + return "executed" + self._worker = threading.Thread( + target=self._run_inspection, + args=(event, max_duration), + daemon=True, + name=f"atlas-action-{event.action_id}", + ) + self._worker.start() + return "accepted" + + def wait_for_idle(self, timeout: float = 120.0) -> bool: + with self._worker_lock: + worker = self._worker + if worker is None: + return True + worker.join(timeout=timeout) + return not worker.is_alive() + + def request_stop(self) -> None: + self._stop_event.set() + + +@dataclass(frozen=True) +class BridgeSettings: + robot_id: str + zenoh_endpoint: str | None + zenoh_config_path: str | None + action_topic: str + result_topic: str + metrics_topic: str + ready_topic: str = READY_TOPIC + + @classmethod + def from_env(cls) -> "BridgeSettings": + def configured(name: str, default: str) -> str: + return os.environ.get(name, default).strip() or default + + endpoint = os.environ.get("ZENOH_ENDPOINT", "").strip() or None + config_path = os.environ.get("ZENOH_CONFIG", "").strip() or None + return cls( + robot_id=configured("ROBOT_ID", ROBOT_ID), + zenoh_endpoint=endpoint, + zenoh_config_path=config_path, + action_topic=configured("ZENOH_ACTION_TOPIC", ACTION_TOPIC), + result_topic=configured("ZENOH_RESULT_TOPIC", RESULT_TOPIC), + metrics_topic=configured("ZENOH_METRICS_TOPIC", METRICS_TOPIC), + ready_topic=configured("ZENOH_READY_TOPIC", READY_TOPIC), + ) + + +def _open_zenoh_session(settings: BridgeSettings): + import zenoh + + if settings.zenoh_config_path: + return zenoh.open(zenoh.Config.from_file(settings.zenoh_config_path)) + if settings.zenoh_endpoint: + config = zenoh.Config.from_json5( + json.dumps({ + "mode": "client", + "connect": {"endpoints": [settings.zenoh_endpoint]}, + }) + ) + return zenoh.open(config) + return zenoh.open(zenoh.Config()) + + +class AtlasZenohBridge: + """Wires :class:`AtlasActionHandler` onto the RoboPay tunnel topics.""" + + def __init__( + self, + settings: BridgeSettings | None = None, + execute: Callable[..., dict] | None = None, + ): + """``execute`` replaces the default episode runner. + + The recorder in ``evidence_recording.py`` uses it to render the very + episode the paid action triggers, rather than a second one run + afterwards — a recording of a different episode would not be evidence + of this one. + """ + try: + import zenoh + except ImportError as error: + raise RuntimeError("Install eclipse-zenoh to run the Atlas bridge.") from error + self._zenoh = zenoh + self.settings = settings or BridgeSettings.from_env() + self.robot_id = self.settings.robot_id + self.action_topic = self.settings.action_topic + self.result_topic = self.settings.result_topic + self.metrics_topic = self.settings.metrics_topic + + self._session = _open_zenoh_session(self.settings) + self._result_publisher = self._session.declare_publisher(self.result_topic) + self._metrics_publisher = self._session.declare_publisher(self.metrics_topic) + self.handler = AtlasActionHandler( + self._publish_result, execute=execute, robot_id=self.robot_id + ) + self._subscriber = self._session.declare_subscriber(self.action_topic, self._on_action) + self._ready_publisher = self._session.declare_publisher(self.settings.ready_topic) + self._ready_publisher.put( + json.dumps({ + "status": "ready", + "profile_id": PROFILE_ID, + "robot_id": self.robot_id, + "skills": sorted(ALLOWED_ACTIONS), + "action_topic": self.action_topic, + "result_topic": self.result_topic, + }, separators=(",", ":")).encode("utf-8") + ) + + def _publish_result(self, payload: bytes) -> None: + self._metrics_publisher.put(payload) + self._result_publisher.put(payload) + + def _on_action(self, sample) -> None: + self.handler.handle(bytes(sample.payload.to_bytes())) + + def close(self) -> None: + self.handler.request_stop() + self.handler.wait_for_idle(timeout=5.0) + self._subscriber.undeclare() + self._ready_publisher.undeclare() + self._result_publisher.undeclare() + self._metrics_publisher.undeclare() + self._session.close() + + def spin(self) -> None: + LOGGER.info( + "Atlas bridge %s listening on %s and publishing results on %s", + self.robot_id, self.action_topic, self.result_topic, + ) + try: + while True: + time.sleep(0.1) + finally: + self.close() + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + AtlasZenohBridge().spin() + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/control_core.py b/bridge/boston_dynamics/atlas_bridge/control_core.py new file mode 100644 index 000000000..1ca6425ba --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/control_core.py @@ -0,0 +1,277 @@ +"""Deterministic shelf-inspection controller shared by every simulator. + +The controller is a state machine driving a damped-least-squares resolved-rate +loop on the right arm:: + + STAND -> REACH(t) -> VERIFY(t) -> ... -> RETURN -> DONE + +Nothing here is a recorded trajectory. Each control step re-reads the measured +end-effector position and the measured arm Jacobian and solves for the next +joint increment, so the same code drives the arm to targets it has never seen +and reacts to whatever the physics engine actually does. + +The module is simulator-independent: callers supply the current end-effector +position and Jacobian, and receive joint position targets back. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +import numpy as np + +from .task import ( + EPISODE_BUDGET_S, + INSPECTION_CHAIN, + INSPECTION_TARGETS, + STANCE_POSE, + InspectionTarget, +) + +POLICY_ID = "atlas-shelf-inspection-dls-v1" + +#: Damping factor of the damped-least-squares pseudo-inverse. Keeps the solve +#: well conditioned when the arm approaches a singular configuration. +DLS_DAMPING = 0.12 +#: Joint-increment gain per control step. +STEP_GAIN = 0.006 +#: Largest joint increment allowed in one control step, in radians. +MAX_JOINT_STEP = 0.01 +#: Steps spent settling into the stance before the first reach. +STAND_SETTLE_STEPS = 400 +#: Steps allowed per target before it is recorded as not reached. +REACH_TIMEOUT_STEPS = 1500 +#: How far a joint target may run ahead of where the joint actually is. +#: Without this the resolved-rate loop keeps integrating while the plant lags, +#: and on a slower servo the arm overshoots its target into the shelf. +MAX_TARGET_LEAD_RAD = 0.12 + + +@dataclass +class ControlPlan: + """One control step's output plus the diagnostics behind it.""" + + phase: str + joint_targets: dict[str, float] + active_target: str + target_index: int + position_error_m: float + hold_progress: int + targets_completed: int + + +@dataclass +class TargetOutcome: + """Per-target result recorded when a target is left.""" + + name: str + reached: bool + final_error_m: float + best_error_m: float + steps: int + + +@dataclass +class InspectionState: + phase: str = "STAND" + index: int = 0 + steps_in_phase: int = 0 + hold_counter: int = 0 + best_error: float = math.inf + outcomes: list[TargetOutcome] = field(default_factory=list) + + +class ShelfInspectionController: + """State machine plus resolved-rate IK for the Atlas shelf-inspection skill.""" + + def __init__( + self, + targets: tuple[InspectionTarget, ...] = INSPECTION_TARGETS, + chain: tuple[str, ...] = INSPECTION_CHAIN, + budget_seconds: float = EPISODE_BUDGET_S, + ) -> None: + if not targets: + raise ValueError("At least one inspection target is required.") + if not chain: + raise ValueError("The inspection chain must contain at least one joint.") + self.targets = targets + self.chain = chain + self.budget_seconds = float(budget_seconds) + self._joint_targets: dict[str, float] = dict(STANCE_POSE) + self._limits: dict[str, tuple[float, float]] = {} + self.state = InspectionState() + + # -- lifecycle --------------------------------------------------------- + def reset(self, joint_limits: dict[str, tuple[float, float]]) -> dict[str, float]: + """Start a new episode and return the initial joint targets.""" + missing = [joint for joint in self.chain if joint not in joint_limits] + if missing: + raise KeyError(f"Missing joint limits for {missing}") + self._limits = dict(joint_limits) + self._joint_targets = dict(STANCE_POSE) + for joint in self.chain: + self._joint_targets.setdefault(joint, 0.0) + self.state = InspectionState() + return dict(self._joint_targets) + + @property + def targets_completed(self) -> int: + return sum(1 for outcome in self.state.outcomes if outcome.reached) + + @property + def finished(self) -> bool: + return self.state.phase == "DONE" + + # -- control step ------------------------------------------------------ + def step( + self, + end_effector: np.ndarray, + jacobian: np.ndarray, + sim_time: float, + joint_angles: dict[str, float] | None = None, + ) -> ControlPlan: + """Advance one control step. + + ``jacobian`` is the 3 x len(chain) positional Jacobian of the end + effector with respect to the inspection chain. ``joint_angles`` are the + measured joint positions, used to stop the joint targets from running + away from the joints they command. + """ + if jacobian.shape != (3, len(self.chain)): + raise ValueError(f"Jacobian must be 3x{len(self.chain)}, got {jacobian.shape}") + + state = self.state + state.steps_in_phase += 1 + + if state.phase == "STAND": + if state.steps_in_phase >= STAND_SETTLE_STEPS: + self._enter("REACH") + return self._plan(0.0) + + if state.phase in ("RETURN", "DONE"): + if state.phase == "RETURN" and state.steps_in_phase >= STAND_SETTLE_STEPS: + self._enter("DONE") + for joint, value in STANCE_POSE.items(): + self._joint_targets[joint] = value + for joint in self.chain: + if joint not in STANCE_POSE: + self._joint_targets[joint] = 0.0 + return self._plan(0.0) + + target = self.targets[state.index] + goal = np.asarray(target.position, dtype=np.float64) + error = goal - np.asarray(end_effector, dtype=np.float64) + distance = float(np.linalg.norm(error)) + state.best_error = min(state.best_error, distance) + + if distance <= target.tolerance_m: + state.hold_counter += 1 + else: + state.hold_counter = 0 + + if state.phase == "REACH": + self._servo(jacobian, error, joint_angles) + if state.hold_counter > 0: + self._enter("VERIFY", keep_index=True) + elif state.phase == "VERIFY": + self._servo(jacobian, error, joint_angles) + if state.hold_counter >= target.hold_steps: + self._finish_target(target, distance, reached=True) + elif state.hold_counter == 0: + self._enter("REACH", keep_index=True) + + timed_out = state.steps_in_phase >= REACH_TIMEOUT_STEPS + over_budget = sim_time >= self.budget_seconds + if (timed_out or over_budget) and state.phase in ("REACH", "VERIFY"): + self._finish_target(target, distance, reached=False) + + return self._plan(distance) + + # -- internals --------------------------------------------------------- + def _servo( + self, + jacobian: np.ndarray, + error: np.ndarray, + joint_angles: dict[str, float] | None, + ) -> None: + """One damped-least-squares resolved-rate increment on the arm chain.""" + jjt = jacobian @ jacobian.T + (DLS_DAMPING**2) * np.eye(3) + delta = jacobian.T @ np.linalg.solve(jjt, error) + for index, joint in enumerate(self.chain): + step = float(np.clip(STEP_GAIN * delta[index], -MAX_JOINT_STEP, MAX_JOINT_STEP)) + low, high = self._limits[joint] + target = self._joint_targets[joint] + step + if joint_angles is not None and joint in joint_angles: + measured = joint_angles[joint] + target = float( + np.clip(target, measured - MAX_TARGET_LEAD_RAD, measured + MAX_TARGET_LEAD_RAD) + ) + self._joint_targets[joint] = float(np.clip(target, low, high)) + + def _finish_target(self, target: InspectionTarget, distance: float, reached: bool) -> None: + state = self.state + state.outcomes.append( + TargetOutcome( + name=target.name, + reached=reached, + final_error_m=round(distance, 5), + best_error_m=round(state.best_error, 5), + steps=state.steps_in_phase, + ) + ) + if state.index + 1 < len(self.targets): + state.index += 1 + self._enter("REACH") + else: + self._enter("RETURN") + + def _enter(self, phase: str, keep_index: bool = False) -> None: + self.state.phase = phase + self.state.steps_in_phase = 0 + self.state.hold_counter = 0 + if not keep_index: + self.state.best_error = math.inf + + def _plan(self, distance: float) -> ControlPlan: + state = self.state + active = ( + self.targets[state.index].name + if state.phase in ("REACH", "VERIFY") + else state.phase.lower() + ) + return ControlPlan( + phase=state.phase, + joint_targets=dict(self._joint_targets), + active_target=active, + target_index=state.index, + position_error_m=distance, + hold_progress=state.hold_counter, + targets_completed=self.targets_completed, + ) + + def diagnostics(self) -> dict: + return { + "policy_id": POLICY_ID, + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": self.state.phase, + "chain": list(self.chain), + "targets_total": len(self.targets), + "targets_completed": self.targets_completed, + "per_target": [ + { + "name": outcome.name, + "reached": outcome.reached, + "final_error_m": outcome.final_error_m, + "best_error_m": outcome.best_error_m, + "control_steps": outcome.steps, + } + for outcome in self.state.outcomes + ], + "parameters": { + "dls_damping": DLS_DAMPING, + "step_gain": STEP_GAIN, + "max_joint_step_rad": MAX_JOINT_STEP, + "reach_timeout_steps": REACH_TIMEOUT_STEPS, + }, + } diff --git a/bridge/boston_dynamics/atlas_bridge/demo_e2e.py b/bridge/boston_dynamics/atlas_bridge/demo_e2e.py new file mode 100644 index 000000000..f1b8e92ac --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/demo_e2e.py @@ -0,0 +1,223 @@ +"""End-to-end demo: x402 payment → Atlas skill execution → settlement evidence. + +Demonstrates the full Fabric bounty flow: +1. Unpaid request → HTTP 402 +2. Valid payment → execute inspect_shelf +3. Success → settlement approved +4. Failure → NO settlement +5. Replay → rejected + +Usage: + python -m bridge.boston_dynamics.atlas_bridge.demo_e2e +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .x402 import X402Verifier, PaymentPolicy +from .payment import SettlementLedger +from .relay import ActionRelay, ActionRequest +from .control_core import POLICY_ID +from .task import PAYMENT_NETWORK, SKILL_PRICE_RAW +from .runner import run_inspection + + +POLICY = PaymentPolicy( + network="eip155:84532", + asset="USDC", + amount=SKILL_PRICE_RAW, + settle_on_failure=False, + replay_protection=True, +) +ROBOT_ID = "atlas-sim-01" + + +def _print_header(title: str) -> None: + print(f"\n{'=' * 60}") + print(f" {title}") + print(f"{'=' * 60}") + + +def _print_result(label: str, result) -> None: + print(f"\n [{label}]") + print(f" HTTP Status: {result.http_status}") + print(f" Status: {result.status}") + print(f" Settlement: {result.settlement_status}") + if result.result.get("targets_completed") is not None: + print(f" Targets: {result.result['targets_completed']}" + f"/{result.result.get('targets_total')}") + if result.result.get("error_code"): + print(f" Error Code: {result.result['error_code']}") + if result.result.get("message"): + print(f" Message: {result.result['message']}") + + +def run_demo(json_output: Path | None = None) -> dict: + verifier = X402Verifier(POLICY) + ledger = SettlementLedger() + + def atlas_executor(req: ActionRequest) -> dict: + print(f"\n >> Executing inspect_shelf (action_id={req.action_id[:16]}...)") + result = run_inspection(max_duration_seconds=8.0) + print(f" >> Execution complete: success={result['success']}, " + f"targets={result['targets_completed']}/{result['targets_total']}") + return result + + relay = ActionRelay( + verifier=verifier, + ledger=ledger, + skill_executor=atlas_executor, + robot_id=ROBOT_ID, + ) + + results = [] + + _print_header("STEP 1: Unpaid Request -> HTTP 402") + req_unpaid = ActionRequest( + action_id="demo-unpaid-001", + robot_id=ROBOT_ID, + skill_id="inspect_shelf", + params={"maxDurationSec": 10}, + payment_header=None, + ) + r1 = relay.handle_action(req_unpaid) + _print_result("UNPAID", r1) + assert r1.http_status == 402, f"Expected 402, got {r1.http_status}" + assert r1.settlement_status == "skipped" + results.append(("unpaid_402", r1.http_status, r1.settlement_status)) + + _print_header("STEP 2: Invalid Payment -> HTTP 400") + req_bad = ActionRequest( + action_id="demo-invalid-001", + robot_id=ROBOT_ID, + skill_id="inspect_shelf", + params={"maxDurationSec": 10}, + payment_header={"amount": "500", "network": PAYMENT_NETWORK, "txHash": "0x7b32195338c9901877c850d2f90e1687f6ee58e516f75840100feece525a4b4d"}, + ) + r2 = relay.handle_action(req_bad) + _print_result("INVALID", r2) + assert r2.http_status == 400 + assert r2.settlement_status == "skipped" + results.append(("invalid_payment", r2.http_status, r2.settlement_status)) + + _print_header("STEP 3: Valid Payment -> Execute -> Success -> Settled") + req_paid = ActionRequest( + action_id="demo-paid-001", + robot_id=ROBOT_ID, + skill_id="inspect_shelf", + params={"maxDurationSec": 10}, + payment_header={ + "amount": SKILL_PRICE_RAW, + "asset": "USDC", + "network": "eip155:84532", + "txHash": "0x94ad618a792cb57bcfa09eaff1feab4e734c6bd9bcd5c7d70acab3a9461923fb", + "payer": "0x1234567890abcdef1234567890abcdef12345678", + "payee": "0xabcdef1234567890abcdef1234567890abcdef12", + }, + ) + r3 = relay.handle_action(req_paid) + _print_result("PAID+EXECUTE", r3) + assert r3.http_status == 200 + assert r3.status == "success" + # This demo runs in-process and holds no wallet, so a successful + # execution becomes eligible for settlement rather than settled. + assert r3.settlement_status == "settlement_eligible" + results.append( + ("paid_success_settlement_eligible", r3.http_status, r3.settlement_status) + ) + + _print_header("STEP 4: Replay Detection -> HTTP 409") + req_replay = ActionRequest( + action_id="demo-replay-001", + robot_id=ROBOT_ID, + skill_id="inspect_shelf", + params={"maxDurationSec": 10}, + payment_header={ + "amount": SKILL_PRICE_RAW, + "asset": "USDC", + "network": "eip155:84532", + "txHash": "0x94ad618a792cb57bcfa09eaff1feab4e734c6bd9bcd5c7d70acab3a9461923fb", + }, + ) + r4 = relay.handle_action(req_replay) + _print_result("REPLAY", r4) + assert r4.http_status == 409 + assert r4.settlement_status == "skipped" + results.append(("replay_detected", r4.http_status, r4.settlement_status)) + + _print_header("STEP 5: Ledger Audit Trail") + ledger_dict = ledger.to_dict() + print(f"\n Total entries: {ledger_dict['total']}") + print(f" Settled on chain: {ledger_dict['settled_on_chain']}") + print(f" Eligible, not paid: {ledger_dict['settlement_eligible_not_on_chain']}") + print(f" Skipped (failure): {ledger_dict['skipped_failure']}") + print(f" Skipped (unpaid): {ledger_dict['skipped_unpaid']}") + + _print_header("SETTLEMENT INVARIANT VERIFICATION") + # This demo authorises settlement but never performs one, so the invariant + # to check is that exactly the successful execution became eligible, and + # that nothing here claims a transfer that did not happen. + eligible_count = sum( + 1 for e in ledger.get_all() if e.status.value == "SETTLEMENT_ELIGIBLE" + ) + on_chain_count = sum(1 for e in ledger.get_all() if e.status.value == "SETTLED") + failed_count = sum(1 for e in ledger.get_all() if "FAILURE" in e.status.value) + unpaid_count = sum(1 for e in ledger.get_all() if "UNPAID" in e.status.value) + print(f"\n Settlement invariant: eligible={eligible_count}, " + f"failed_no_settle={failed_count}, unpaid_no_settle={unpaid_count}") + print(f" [OK] Only successful execution became eligible: {eligible_count == 1}") + print(f" [OK] Failed executions not settled: {failed_count == 0}") + print(f" [OK] Unpaid requests correctly skipped: {unpaid_count >= 1}") + print(f" [OK] Nothing in this demo claims an on-chain transfer: " + f"{on_chain_count == 0}") + + evidence = { + "demo": "atlas_e2e_x402_flow", + "policy_id": POLICY_ID, + "robot_id": ROBOT_ID, + "steps": [ + {"step": 1, "name": "unpaid_402", "http_status": r1.http_status, + "settlement": r1.settlement_status}, + {"step": 2, "name": "invalid_payment", "http_status": r2.http_status, + "settlement": r2.settlement_status}, + {"step": 3, "name": "protocol_valid_payment_executed", "http_status": r3.http_status, + "settlement": r3.settlement_status, + "targets_completed": r3.result.get("targets_completed", 0), + "mean_position_error_m": r3.result.get("mean_position_error_m")}, + {"step": 4, "name": "replay_detected", "http_status": r4.http_status, + "settlement": r4.settlement_status}, + ], + "settlement_ledger": ledger_dict, + } + # Reproducing the walkthrough must not rewrite the committed evidence, so + # the artefact is only written where the caller explicitly asks for it. + if json_output is not None: + json_output.parent.mkdir(parents=True, exist_ok=True) + json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f"\n Evidence written to: {json_output}") + + _print_header("DEMO COMPLETE") + print("\n All assertions passed. Payment safety invariant holds:") + print(" - Unpaid -> 402 -> no execution -> no settlement") + print(" - Invalid payment -> rejected -> no settlement") + print(" - Protocol-valid payment -> execute -> success -> settlement eligible") + print(" (no value moves here; real_paid_run.py is the settling path)") + print(" - Replay -> 409 -> no settlement") + + return evidence + + +def main() -> None: + # Reproducing the demo must not silently rewrite the committed evidence, so + # the artefact is only written where the caller asks for it. + parser = argparse.ArgumentParser(description="x402 payment-gate walkthrough.") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + run_demo(args.json_output) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/demo_fabric_e2e.py b/bridge/boston_dynamics/atlas_bridge/demo_fabric_e2e.py new file mode 100644 index 000000000..9b7ed5555 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/demo_fabric_e2e.py @@ -0,0 +1,731 @@ +"""The whole path, with nothing stood in for. + +``demo_go_tunnel.py`` drives the real Go tunnel but stands in for the hosted +Fabric backend with a local WebSocket proxy. ``real_paid_run.py`` settles a real +payment but reaches the robot over Zenoh directly. This module removes both +substitutions and runs the path the bounty actually describes:: + + client + -> Fabric relay https://api.fabric.foundation/api/core (hosted, real) + -> Go tunnel this repository's binary, dialled out over WSS + -> x402 middleware -> live facilitator + -> Zenoh robot/tunnel/action + -> Atlas bridge -> MuJoCo, three inspection targets + -> Zenoh robot/tunnel/result + -> Fabric relay terminal status, correlated by action_id + -> settlement USDC on Base Sepolia + +Four things are demonstrated here that no other artifact in this profile shows: + +* **Robot discovery** — the relay is asked what robot is connected. +* **Skill discovery and price discovery** — the payment is built from the price + the relay advertises, not from a constant compiled into this script. +* **The relay's own refusal** — an unpaid action is refused with `402` by the + hosted service, and the payment requirements come back in its response. +* **The relay's terminal status** — the result is read back from the relay + rather than from Zenoh, correlated by `action_id`. + +The authorization nonce is ``keccak256(action_id)``, as in ``real_paid_run.py``, +so the settlement stays verifiably bound to the action it paid for. + +The signing key is read from ``SETTLEMENT_PRIVATE_KEY`` or +``SETTLEMENT_MNEMONIC`` and is never printed, logged, or written to any file. + +``--dry-run`` stops after the 402 and signs nothing, which is enough to prove +discovery and the relay's refusal without spending anything. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +from .bridge import AtlasZenohBridge +from .task import ( + PAYMENT_NETWORK, + SKILL_PRICE_RAW, + SKILL_PRICE_USDC, + USDC_BASE_SEPOLIA, +) + +FABRIC_API_BASE = os.environ.get( + "FABRIC_API_BASE_URL", "https://api.fabric.foundation/api/core" +) +FABRIC_WS_URL = os.environ.get( + "PROXY_WS_URL", "wss://api.fabric.foundation/api/core/ws/robot" +) +DEFAULT_PAYEE = "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" +SKILL_ID = "inspect_shelf" +PROFILE_ID = "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" +PROFILE_DIR = ( + Path(__file__).resolve().parents[3] + / "registry" / "vendors" / "boston-dynamics" / "atlas" / PROFILE_ID +) +RPC_URL = "https://sepolia.base.org" +EXPLORER = "https://sepolia.basescan.org" +USER_AGENT = "robopay-atlas-bridge/1.0" + +EPISODE_SECONDS = 20.0 +TUNNEL_CONNECT_TIMEOUT_S = 90.0 +STATUS_TIMEOUT_S = 300.0 +#: POST /action answers 202 straight away, but the status polling that follows +#: has to outlast the episode budget plus the tunnel's own margin. +HTTP_TIMEOUT_S = 240.0 +TERMINAL_STATES = {"succeeded", "failed", "timeout", "settlement_failed"} +#: A settled action needs one more poll than a finished one: the tunnel +#: settles after the result arrives, so "succeeded" can precede "settled". +SETTLEMENT_POLL_S = 90.0 + + +# -- HTTP ------------------------------------------------------------------- +def _request(method: str, url: str, body: dict | None = None, + headers: dict | None = None) -> tuple[int, dict, dict]: + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request(url, data=data, method=method) + request.add_header("user-agent", USER_AGENT) + if data is not None: + request.add_header("content-type", "application/json") + for key, value in (headers or {}).items(): + request.add_header(key, value) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response: + raw = response.read().decode() or "{}" + return response.status, _json(raw), dict(response.headers) + except urllib.error.HTTPError as error: + raw = error.read().decode() or "{}" + return error.code, _json(raw), dict(error.headers) + + +def _json(raw: str) -> dict: + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {"raw": raw[:400]} + return parsed if isinstance(parsed, dict) else {"body": parsed} + + +def _decode_header(value: str | None) -> dict: + """x402 sends requirements base64-encoded, occasionally as plain JSON.""" + if not value: + return {} + try: + return json.loads(base64.b64decode(value).decode()) + except Exception: # noqa: BLE001 - fall back to a plain body + return _json(value) + + +def _header(headers: dict, name: str) -> str | None: + for key, value in headers.items(): + if key.upper() == name.upper(): + return value + return None + + +def _rpc(method: str, params: list): + request = urllib.request.Request( + RPC_URL, + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(), + headers={"content-type": "application/json", "user-agent": USER_AGENT}, + ) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response: + return json.loads(response.read()).get("result") + + +# -- the tunnel ------------------------------------------------------------- +class Tunnel: + """This repository's Go tunnel, dialled out to the hosted Fabric relay.""" + + def __init__(self, binary: Path, robot_id: str, payee: str, workdir: Path) -> None: + self.log_path = workdir / "tunnel.log" + (workdir / "config.json").write_text(json.dumps({ + "robot_id": robot_id, + "evm_payee_address": payee, + "price": f"${SKILL_PRICE_USDC}", + "network": PAYMENT_NETWORK, + }, indent=2), encoding="utf-8") + + environment = dict(os.environ) + environment["PROXY_WS_URL"] = FABRIC_WS_URL + # zenohc.dll sits beside the binary on Windows. + environment["PATH"] = f"{binary.parent}{os.pathsep}{environment.get('PATH', '')}" + self._log = self.log_path.open("w", encoding="utf-8") + self.process = subprocess.Popen( + [str(binary)], cwd=str(workdir), env=environment, + stdout=self._log, stderr=subprocess.STDOUT, text=True, + ) + + def wait_until_connected(self, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self.process.poll() is not None: + return False + text = self.log_path.read_text(encoding="utf-8", errors="replace") + if "ws connected to proxy" in text or "connected to proxy" in text: + return True + time.sleep(1.0) + return False + + def close(self) -> None: + self.process.terminate() + try: + self.process.wait(timeout=20) + except subprocess.TimeoutExpired: + self.process.kill() + self._log.close() + + +# -- the payment ------------------------------------------------------------ +def sign_for(action_id: str, accepted: dict) -> tuple[dict, str, str]: + """Sign an authorization matching the requirements the *relay* sent back. + + The amount, payee, asset and network all come from the 402 response rather + than from constants here, so the payment is for the price the robot + actually advertises. The nonce is derived from the action id, which is what + keeps the eventual settlement bound to this action. + """ + from eth_account import Account + from eth_utils import keccak + + key = os.environ.get("SETTLEMENT_PRIVATE_KEY", "").strip() + mnemonic = os.environ.get("SETTLEMENT_MNEMONIC", "").strip() + if key: + account = Account.from_key(key) + elif mnemonic: + Account.enable_unaudited_hdwallet_features() + index = int(os.environ.get("SETTLEMENT_ACCOUNT_INDEX", "0")) + account = Account.from_mnemonic(mnemonic, account_path=f"m/44'/60'/0'/0/{index}") + else: + raise SystemExit( + "Set SETTLEMENT_PRIVATE_KEY or SETTLEMENT_MNEMONIC in your own shell." + ) + + nonce = keccak(text=action_id) + # Every field comes from the 402, and a missing one is fatal rather than + # filled in. The claim this run exists to support is that the payment is + # built from what the relay quoted; defaulting to a compiled-in price or + # payee would let it pass on a number the relay never sent, which is the + # one way this evidence could be quietly wrong. + # x402 v2 calls the price "amount"; v1 called it "maxAmountRequired". + quoted = accepted.get("amount") or accepted.get("maxAmountRequired") + missing = [ + name for name, present in ( + ("amount", quoted), + ("payTo", accepted.get("payTo")), + ("asset", accepted.get("asset")), + ("network", accepted.get("network")), + ("extra", accepted.get("extra")), + ) if not present + ] + if missing: + raise SystemExit( + "the relay's 402 did not quote " + ", ".join(missing) + + "; refusing to sign a payment this profile made up rather than " + "one the robot asked for" + ) + value = int(quoted) + payee = accepted["payTo"] + asset = accepted["asset"] + extra = accepted["extra"] + valid_before = int(time.time()) + max( + int(accepted.get("maxTimeoutSeconds") or 0), 1800 + ) + + typed = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + }, + "primaryType": "TransferWithAuthorization", + "domain": { + "name": extra.get("name", "USDC"), + "version": extra.get("version", "2"), + "chainId": 84532, + "verifyingContract": asset, + }, + "message": { + "from": account.address, "to": payee, "value": value, + "validAfter": 0, "validBefore": valid_before, "nonce": nonce, + }, + } + signature = account.sign_typed_data(full_message=typed).signature + authorization = { + "from": account.address, "to": payee, "value": str(value), + "validAfter": "0", "validBefore": str(valid_before), + "nonce": "0x" + nonce.hex(), + } + return authorization, "0x" + signature.hex().lstrip("0x"), account.address + + +def payment_header(authorization: dict, signature: str, accepted: dict, + x402_version: int) -> str: + """Build the payment header the tunnel's own middleware will accept. + + x402 v2 matches an incoming payment against the advertised requirements on + scheme, network, amount, asset **and** payTo, all read from an ``accepted`` + object on the payload. Sending scheme and network at the top level — the v1 + shape — matches nothing, and the middleware answers "No matching payment + requirements". Echoing the requirements object verbatim is therefore not + redundancy: it is what says which of the advertised options is being paid. + """ + payload = { + "x402Version": x402_version, + "payload": {"signature": signature, "authorization": authorization}, + } + if x402_version >= 2: + payload["accepted"] = accepted + else: + payload["scheme"] = accepted.get("scheme", "exact") + payload["network"] = accepted.get("network", PAYMENT_NETWORK) + return base64.b64encode(json.dumps(payload).encode()).decode() + + +# -- on-chain confirmation -------------------------------------------------- +def confirm_on_chain(tx_hash: str, action_id: str) -> dict: + from eth_utils import keccak + + TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + AUTH_USED = "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5" + + receipt = None + for _ in range(40): + receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) + if receipt: + break + time.sleep(3) + if not receipt: + return {"confirmed": False, "reason": "no receipt"} + + expected = "0x" + keccak(text=action_id).hex() + transfer, used_nonce = {}, "" + for log in receipt.get("logs", []): + topics = log.get("topics", []) + if topics and topics[0].lower() == TRANSFER and len(topics) >= 3: + transfer = { + "token_contract": log["address"], + "from": "0x" + topics[1][-40:], + "to": "0x" + topics[2][-40:], + "raw_amount": int(log["data"], 16), + } + elif topics and topics[0].lower() == AUTH_USED and len(topics) >= 3: + used_nonce = topics[2] + + raw = transfer.get("raw_amount", 0) + return { + "confirmed": int(receipt.get("status", "0x0"), 16) == 1, + "block_number": int(receipt.get("blockNumber", "0x0"), 16), + "submitted_by": (_rpc("eth_getTransactionByHash", [tx_hash]) or {}).get("from", ""), + "explorer": f"{EXPLORER}/tx/{tx_hash}", + "transfer": {**transfer, "amount_usdc": raw / 1_000_000 if raw else 0}, + "authorization_nonce": used_nonce, + "expected_nonce_from_action_id": expected, + "nonce_binds_settlement_to_action": used_nonce.lower() == expected.lower(), + "asset_is_declared_usdc": transfer.get("token_contract", "").lower() + == USDC_BASE_SEPOLIA.lower(), + } + + +def authorization_used_on_chain(payer: str, action_id: str, + settled_in_block: int = 0) -> dict: + """Ask the token contract whether this authorization was ever spent. + + Proving that a failed action settled is easy — there is a transaction to + point at. Proving it did *not* is harder, because "we recorded no hash" is + an absence of evidence rather than evidence of absence. EIP-3009 tokens keep + their own map of spent authorization nonces and expose it as + ``authorizationState(authorizer, nonce)``, so the question can be put to the + contract instead: for a nonce derived from the action id, a false answer is + the token itself saying nobody was charged for this action. + + When a settlement exists, the question is pinned to the block that contains + it and asked only once the chain head has moved past that block. A public + endpoint will serve a receipt before it has applied the block's state, and + answering from that window reports a spent authorization as unspent — which + is exactly the wrong direction for this check to be wrong in. Pinning to a + block keeps the answer deterministic rather than retrying until it agrees. + """ + from eth_utils import keccak + + nonce = keccak(text=action_id) + selector = "0x" + keccak(text="authorizationState(address,bytes32)").hex()[:8] + data = selector + payer[2:].lower().rjust(64, "0") + nonce.hex() + + tag = "latest" + if settled_in_block: + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + try: + head = int(_rpc("eth_blockNumber", []), 16) + except Exception: # noqa: BLE001 - keep waiting for a usable answer + head = 0 + if head >= settled_in_block + 2: + break + time.sleep(3) + tag = hex(settled_in_block) + + try: + raw = _rpc("eth_call", [{"to": USDC_BASE_SEPOLIA, "data": data}, tag]) + used = bool(int(raw, 16)) + answered = True + except Exception: # noqa: BLE001 - an unanswered question is not a proof + used, answered = False, False + return { + "authorizer": payer, + "nonce": "0x" + nonce.hex(), + "nonce_derivation": "keccak256(action_id)", + "contract": USDC_BASE_SEPOLIA, + "method": "authorizationState(address,bytes32)", + "queried_at_block": tag, + "used": used, + "queried": answered, + } + + +# -- the run ---------------------------------------------------------------- +def run(binary: Path, robot_id: str, payee: str, dry_run: bool, + max_duration: float = EPISODE_SECONDS, + expect_failure: bool = False) -> dict: + action_id = f"atlas-inspect-{int(time.time())}" + steps: list[dict] = [] + discovery: dict = {} + chain = None + terminal = None + + print("=" * 74) + print(" Atlas through the hosted Fabric relay — nothing stood in for") + print("=" * 74) + print(f" relay : {FABRIC_API_BASE}") + print(f" robot_id : {robot_id}") + print(f" action_id : {action_id}") + + # The bridge and the tunnel have to agree on the identity the relay + # routes by; the bridge reads it from the environment. + os.environ["ROBOT_ID"] = robot_id + # Discovery answers from the profile's own catalogue, so the price a caller + # is quoted cannot drift from the price the registry publishes. + os.environ.setdefault("SKILL_CATALOG_PATH", str(PROFILE_DIR / "skill-catalog.json")) + os.environ.setdefault("ROBOT_PROFILE_ID", PROFILE_ID) + bridge = AtlasZenohBridge() + workdir = Path(tempfile.mkdtemp(prefix="atlas_fabric_")) + tunnel = Tunnel(binary, robot_id, payee, workdir) + try: + if not tunnel.wait_until_connected(TUNNEL_CONNECT_TIMEOUT_S): + tail = tunnel.log_path.read_text(encoding="utf-8", errors="replace")[-800:] + raise SystemExit(f"the tunnel never reached the relay:\n{tail}") + print(" tunnel connected to the hosted relay\n") + + # 1. Robot discovery, then skill and price discovery. + status, skills_body, _ = _request( + "GET", f"{FABRIC_API_BASE}/robots/{robot_id}/skills" + ) + skills = skills_body.get("skills") or [] + discovery = { + "http_status": status, + "robot_id": skills_body.get("robot_id") or robot_id, + "robot_discovered": status == 200, + "skills": skills, + "skill_ids": sorted(s.get("skill_id", "") for s in skills), + } + chosen = next((s for s in skills if s.get("skill_id") == SKILL_ID), {}) + discovered_price = str(chosen.get("price_usdc") or "") + discovery["discovered_skill"] = chosen.get("skill_id", "") + discovery["discovered_price_usdc"] = discovered_price + print(f" [discovery] HTTP {status} skills={discovery['skill_ids']}") + print(f" {SKILL_ID} @ {discovered_price or '?'} USDC") + steps.append({"step": "discovery", **discovery}) + + action_url = f"{FABRIC_API_BASE}/robots/{robot_id}/action" + action_body = { + "action": SKILL_ID, + "skill_id": SKILL_ID, + "robot_id": robot_id, + "action_id": action_id, + "idempotency_key": action_id, + "params": {"maxDurationSec": max_duration}, + } + + # 2. The relay itself refuses an unpaid action. + status, unpaid_body, headers = _request("POST", action_url, action_body) + requirements = _decode_header(_header(headers, "PAYMENT-REQUIRED")) + accepted = (requirements.get("accepts") or [{}])[0] + x402_version = int(requirements.get("x402Version") or 1) + print(f" [unpaid] HTTP {status} payTo={accepted.get('payTo')}" + f" amount={accepted.get('amount') or accepted.get('maxAmountRequired')}" + f" network={accepted.get('network')} x402Version={x402_version}") + steps.append({ + "step": "unpaid_action", "http_status": status, + "payment_required_header": bool(requirements), + "requirements": accepted, "x402_version": x402_version, + "body": unpaid_body, "refused_by": "hosted Fabric relay + tunnel x402 middleware", + }) + + if dry_run: + print("\n dry run: nothing signed, nothing spent") + return _evidence(robot_id, action_id, payee, discovery, steps, + None, None, dry_run=True) + + # 3. Pay for the price the robot advertised. + authorization, signature, payer = sign_for(action_id, accepted) + print(f" payer : {payer}") + header = payment_header(authorization, signature, accepted, x402_version) + status, paid_body, paid_headers = _request( + "POST", action_url, action_body, {"PAYMENT-SIGNATURE": header} + ) + # Kept for the rejecting cases: when the gate refuses a payment it + # reports why in this header. On the accepting path it carries nothing + # useful, because settlement has not happened yet — the tunnel answers + # 202 first and settles from its watcher once the result arrives. + payment_response = _decode_header(_header(paid_headers, "PAYMENT-RESPONSE")) + print(f" [paid] HTTP {status} {paid_body.get('status') or ''}" + f" action_id={paid_body.get('action_id') or ''}") + steps.append({ + "step": "paid_action", "http_status": status, + "accepted": status == 202, + "immediate": True, + "action_id_echoed": paid_body.get("action_id"), + "status_url": paid_body.get("status_url"), + "payment_response": payment_response, + # The tunnel answers as soon as the action is accepted and settles + # from a background watcher, so acceptance says nothing about the + # outcome and nothing about payment. + "settlement_ordering": "settled by the tunnel after the result, only on success", + "body": paid_body, + }) + if status != 202 and not expect_failure: + return _evidence(robot_id, action_id, payee, discovery, steps, + None, None, payer=payer) + + # 4. The relay's own terminal status, not Zenoh's. + status_url = f"{FABRIC_API_BASE}/robots/{robot_id}/action/{action_id}/status" + deadline = time.monotonic() + STATUS_TIMEOUT_S + while time.monotonic() < deadline: + code, candidate, _ = _request("GET", status_url) + if code == 200 and candidate.get("state") in TERMINAL_STATES: + terminal = candidate + # A successful episode settles a moment later, from the tunnel's + # watcher, so keep reading until the settlement half lands too. + if candidate.get("state") != "succeeded": + break + settle_deadline = time.monotonic() + SETTLEMENT_POLL_S + while time.monotonic() < settle_deadline: + code, candidate, _ = _request("GET", status_url) + if code == 200 and (candidate.get("settled") + or candidate.get("settlement_error")): + terminal = candidate + break + time.sleep(3) + break + time.sleep(3) + if terminal is None: + print(" no terminal status from the relay within the timeout") + return _evidence(robot_id, action_id, payee, discovery, steps, + None, None, payer=payer) + + result = terminal.get("result") or {} + print(f" [relay] state={terminal.get('state')}" + f" settled={terminal.get('settled')}" + f" targets={result.get('targets_completed')}/{result.get('targets_total')}") + settlement = terminal.get("settlement") or {} + steps.append({ + "step": "terminal_status", "state": terminal.get("state"), + "action_id": terminal.get("action_id"), + "correlated": terminal.get("action_id") == action_id, + "params_hash": terminal.get("params_hash"), + "idempotency_key": terminal.get("idempotency_key"), + "targets_completed": result.get("targets_completed"), + "targets_total": result.get("targets_total"), + "settled": bool(terminal.get("settled")), + "settlement": settlement or None, + "settlement_error": terminal.get("settlement_error") or None, + "read_from": "hosted Fabric relay", + }) + + # 5. The settlement the tunnel performed — or did not. + tx_hash = settlement.get("transaction") or "" + if tx_hash: + chain = confirm_on_chain(tx_hash, action_id) + print(f" [chain] block {chain['block_number']}" + f" {chain['transfer'].get('amount_usdc')} USDC" + f" bound to action_id: {chain['nonce_binds_settlement_to_action']}") + steps.append({"step": "settlement", "tx_hash": tx_hash, **chain}) + + # Asked last, and deliberately so: a settlement that has been submitted + # but not yet mined has not spent its authorization, so putting this + # question before the receipt is confirmed answers about a transaction + # that has not landed. On the failing path there is no receipt to wait + # for and the answer is immediate. + authorization = authorization_used_on_chain( + payer, action_id, (chain or {}).get("block_number", 0) + ) + print(f" [token] authorization spent on chain: {authorization['used']}") + steps.append({"step": "authorization_state", **authorization}) + return _evidence(robot_id, action_id, payee, discovery, steps, + terminal, chain, payer=payer, expect_failure=expect_failure) + finally: + tunnel.close() + bridge.close() + print(f"\n tunnel log: {tunnel.log_path}") + + +def _evidence(robot_id, action_id, payee, discovery, steps, terminal, chain, + payer: str = "", dry_run: bool = False, + expect_failure: bool = False) -> dict: + def step(name: str) -> dict: + return next((s for s in steps if s.get("step") == name), {}) + + evidence = { + "evidence": "atlas_fabric_relay_end_to_end", + "profile_id": PROFILE_ID, + "relay": FABRIC_API_BASE, + "relay_transport": FABRIC_WS_URL, + "stood_in_for": "nothing — the relay, the tunnel, Zenoh, the simulator and " + "the facilitator are all the real components", + "robot_id": robot_id, + "skill_id": SKILL_ID, + "action_id": action_id, + "idempotency_key": action_id, + "payer": payer, + "payee": payee, + "network": PAYMENT_NETWORK, + "asset": USDC_BASE_SEPOLIA, + "dry_run": dry_run, + "discovery": discovery, + "steps": steps, + "terminal_status": terminal, + "on_chain": chain, + } + settled = bool(next((s for s in steps if s.get("step") == "terminal_status"), {}) + .get("settled")) + failed = (terminal or {}).get("state") == "failed" + if not dry_run: + evidence["payment_safety"] = { + "settlement_ordering": "POST /action answers 202 immediately; the tunnel " + "settles from a background watcher and only when " + "the correlated result reports success", + "execution_failed": failed, + "settled": settled, + "settled_despite_failure": failed and settled, + } + checks = [ + ("the relay reported the robot connected", discovery.get("robot_discovered") is True), + ("skill discovery returned the inspection skill", + discovery.get("discovered_skill") == SKILL_ID), + ("the price was discovered, not assumed", + discovery.get("discovered_price_usdc") == SKILL_PRICE_USDC), + ("the relay quoted the discovered price", + str((step("unpaid_action").get("requirements") or {}).get("amount") + or (step("unpaid_action").get("requirements") or {}).get("maxAmountRequired") + or "") == SKILL_PRICE_RAW), + ("the relay refused an unpaid action with 402", + step("unpaid_action").get("http_status") == 402), + ("the relay advertised payment requirements", + bool(step("unpaid_action").get("payment_required_header"))), + ] + if not dry_run and expect_failure: + # The unhappy path, and the property that matters most about it: a paid + # action that does not succeed must not be settled. + paid = step("paid_action") + result = (terminal or {}).get("result") or {} + checks += [ + ("the action was accepted immediately, as the contract says", + paid.get("http_status") == 202), + ("the tunnel did not settle a failed action", + step("terminal_status").get("settled") is False), + ("no settlement transaction exists", + not (step("terminal_status").get("settlement") or {}).get("transaction")), + ("nothing was transferred on chain", chain is None), + # The token's own record, so the absence is evidence rather than + # merely an absent record on our side. + ("the token contract has no record of the authorization being spent", + step("authorization_state").get("queried") is True + and step("authorization_state").get("used") is False), + ("the status endpoint reported the action failed", + step("terminal_status").get("state") == "failed"), + ("the status carries the real reason, not a generic one", + result.get("success") is False and bool(result.get("error_code"))), + ("the failed action is still correlated by action_id", + bool(step("terminal_status").get("correlated"))), + ] + elif not dry_run: + checks += [ + ("the paid action was accepted", bool(step("paid_action").get("accepted"))), + ("the relay reported the action succeeded", + step("terminal_status").get("state") == "succeeded"), + ("every inspection target was reached", + step("terminal_status").get("targets_completed") + == step("terminal_status").get("targets_total") + and bool(step("terminal_status").get("targets_total"))), + ("the terminal status is correlated by action_id", + bool(step("terminal_status").get("correlated"))), + ("the relay answered 202 immediately, before the robot finished", + step("paid_action").get("http_status") == 202), + ("the tunnel settled only after the result", + bool(step("terminal_status").get("settled"))), + ("the settlement is confirmed on Base Sepolia", + bool(chain and chain.get("confirmed"))), + ("the on-chain nonce is keccak256(action_id)", + bool(chain and chain.get("nonce_binds_settlement_to_action"))), + ("the token contract records the authorization as spent", + step("authorization_state").get("used") is True), + ] + print("\n" + "=" * 74) + print(" INVARIANTS") + print("=" * 74) + for label, ok in checks: + print(f" [{'OK' if ok else '!!'}] {label}") + evidence["invariants"] = {label: ok for label, ok in checks} + evidence["all_invariants_hold"] = all(ok for _, ok in checks) + return evidence + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run the Atlas skill through the hosted Fabric relay." + ) + parser.add_argument("--tunnel", type=Path, required=True) + parser.add_argument("--robot-id", default=f"atlas-sim-{int(time.time())}") + parser.add_argument("--payee", default=DEFAULT_PAYEE) + parser.add_argument("--max-duration", type=float, default=EPISODE_SECONDS, + help="Episode budget. Too small a value makes the episode " + "fail, which is how the failure path is exercised.") + parser.add_argument("--expect-failure", action="store_true", + help="Assert the episode failed, to prove a failed run is " + "reported as failed rather than quietly as success.") + parser.add_argument("--dry-run", action="store_true", + help="Stop after the 402; sign nothing, spend nothing.") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + if not args.tunnel.is_file(): + raise SystemExit(f"tunnel binary not found: {args.tunnel}") + + evidence = run(args.tunnel, args.robot_id, args.payee, args.dry_run, + args.max_duration, args.expect_failure) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f" evidence written to {args.json_output}") + raise SystemExit(0 if evidence["all_invariants_hold"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/demo_go_tunnel.py b/bridge/boston_dynamics/atlas_bridge/demo_go_tunnel.py new file mode 100644 index 000000000..c121da5e1 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/demo_go_tunnel.py @@ -0,0 +1,270 @@ +"""End-to-end payment enforcement through the repository's real Go tunnel. + +``demo_tunnel.py`` exercises the Zenoh transport with a Python client. This +module goes one layer further out and drives the **actual Go tunnel binary from +this repository** — the one that mounts the upstream x402 gin middleware and a +real facilitator client — so the payment decision is made by the tunnel, not by +any Python code:: + + proxy (stands in for the Fabric backend) + -> WebSocket ws://…/api/core/ws/robot?id= + -> Go tunnel POST /action + -> x402 middleware -> live facilitator + -> Zenoh robot/tunnel/action + -> Atlas bridge -> MuJoCo + -> Zenoh robot/tunnel/result + +The tunnel connects *out* to a proxy rather than listening, so this module +implements the small envelope protocol the tunnel speaks (``internal/client.go``) +and stands in for the hosted Fabric backend. Everything from the tunnel inwards +is the real thing. + +Requires the tunnel to be built once; see ``TUNNEL_BUILD.md``. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import subprocess +import uuid +from pathlib import Path + +from .bridge import ACTION_TOPIC, RESULT_TOPIC, ROBOT_ID, AtlasZenohBridge + +PROXY_HOST = "127.0.0.1" +PROXY_PORT = 8791 +PROXY_PATH = "/api/core/ws/robot" +#: How long to wait for the tunnel to dial in. +CONNECT_TIMEOUT_S = 45.0 +#: How long to wait for the tunnel's HTTP response envelope. +RESPONSE_TIMEOUT_S = 90.0 +#: How long to wait for the correlated simulator result on Zenoh. +RESULT_TIMEOUT_S = 240.0 + + +def _forged_payment_header() -> str: + """A structurally valid x402 header that was never signed.""" + payload = { + "x402Version": 1, + "scheme": "exact", + "network": "base-sepolia", + "payload": { + "signature": "0x" + "11" * 65, + "authorization": { + "from": "0x520C3Ff276456A217c0dFadABeEb2d7081d6cCd4", + "to": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "value": "1000", + "validAfter": "0", + "validBefore": "9999999999", + "nonce": "0x" + "22" * 32, + }, + }, + } + return base64.b64encode(json.dumps(payload).encode()).decode() + + +class FabricProxy: + """The minimal half of the Fabric backend the tunnel actually talks to.""" + + def __init__(self) -> None: + self.connection = None + self.connected = asyncio.Event() + self._pending: dict[str, asyncio.Future] = {} + + async def handler(self, websocket) -> None: + import websockets + + self.connection = websocket + self.connected.set() + try: + await self._pump(websocket) + except websockets.exceptions.ConnectionClosed: + pass # the tunnel is torn down at the end of the run + + async def _pump(self, websocket) -> None: + async for message in websocket: + envelope = json.loads(message) + if envelope.get("type") != "response": + continue + future = self._pending.pop(envelope.get("id", ""), None) + if future is not None and not future.done(): + future.set_result(envelope) + + async def request(self, method: str, path: str, body: dict, headers: dict) -> dict: + """Send one HTTP request through the tunnel and await its response.""" + request_id = uuid.uuid4().hex + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[request_id] = future + await self.connection.send(json.dumps({ + "type": "request", + "id": request_id, + "method": method, + "path": path, + "headers": headers, + # Go marshals []byte as base64, and unmarshals it the same way. + "body": base64.b64encode(json.dumps(body).encode()).decode(), + })) + envelope = await asyncio.wait_for(future, timeout=RESPONSE_TIMEOUT_S) + raw = envelope.get("body") + decoded = base64.b64decode(raw).decode() if raw else "" + try: + parsed = json.loads(decoded) if decoded else {} + except json.JSONDecodeError: + parsed = {"raw": decoded} + return { + "status": envelope.get("status"), + "headers": envelope.get("headers") or {}, + "body": parsed, + } + + +def _action_body(action_id: str, duration: float) -> dict: + return { + "action": "inspect_shelf", + "skill_id": "inspect_shelf", + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": f"idem-{action_id}", + "params": {"maxDurationSec": duration}, + } + + +async def _run(tunnel_binary: Path, duration: float) -> dict: + import websockets + + proxy = FabricProxy() + results: dict[str, dict] = {} + + bridge = AtlasZenohBridge() + import zenoh + + session = zenoh.open(zenoh.Config()) + session.declare_subscriber( + RESULT_TOPIC, + lambda sample: results.__setitem__( + json.loads(bytes(sample.payload.to_bytes()).decode())["action_id"], + json.loads(bytes(sample.payload.to_bytes()).decode()), + ), + ) + + server = await websockets.serve(proxy.handler, PROXY_HOST, PROXY_PORT) + print("=" * 70) + print(" Atlas payment enforcement through the real Go tunnel") + print("=" * 70) + print(f" proxy listening on ws://{PROXY_HOST}:{PROXY_PORT}{PROXY_PATH}") + print(f" bridge listening on {ACTION_TOPIC} as {bridge.robot_id}") + + environment = dict(os.environ) + environment["PROXY_WS_URL"] = f"ws://{PROXY_HOST}:{PROXY_PORT}{PROXY_PATH}" + environment["PATH"] = f"{tunnel_binary.parent}{os.pathsep}{environment.get('PATH', '')}" + tunnel = subprocess.Popen( + [str(tunnel_binary)], cwd=str(tunnel_binary.parent), env=environment, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + + steps: list[dict] = [] + try: + await asyncio.wait_for(proxy.connected.wait(), timeout=CONNECT_TIMEOUT_S) + print(" Go tunnel connected to the proxy\n") + + # 1. No payment at all. + action_id = f"act-unpaid-{uuid.uuid4().hex[:8]}" + unpaid = await proxy.request( + "POST", "/action", _action_body(action_id, duration), {"Content-Type": "application/json"} + ) + print(f" [unpaid] HTTP {unpaid['status']}") + print(f" payment-required : {'PAYMENT-REQUIRED' in {k.upper() for k in unpaid['headers']}}") + steps.append({ + "step": "unpaid", "action_id": action_id, + "http_status": unpaid["status"], + "payment_required_header": any( + k.upper() == "PAYMENT-REQUIRED" for k in unpaid["headers"] + ), + "executed": action_id in results, + "decided_by": "go tunnel x402 middleware", + }) + + # 2. A payment the middleware will hand to the live facilitator. + action_id = f"act-forged-{uuid.uuid4().hex[:8]}" + forged = await proxy.request( + "POST", "/action", _action_body(action_id, duration), + {"Content-Type": "application/json", "PAYMENT-SIGNATURE": _forged_payment_header()}, + ) + print(f" [forged payment] HTTP {forged['status']}") + detail = json.dumps(forged["body"])[:160] + print(f" tunnel said : {detail}") + steps.append({ + "step": "forged-payment", "action_id": action_id, + "http_status": forged["status"], + "response": forged["body"], + "executed": action_id in results, + "decided_by": "go tunnel x402 middleware + live facilitator", + }) + + await asyncio.sleep(3) # give any stray action time to surface + finally: + tunnel.terminate() + try: + tunnel.wait(timeout=20) + except subprocess.TimeoutExpired: + tunnel.kill() + server.close() + await server.wait_closed() + session.close() + bridge.close() + + evidence = { + "demo": "atlas_go_tunnel_e2e", + "tunnel": "repository Go tunnel binary (x402 gin middleware + facilitator client)", + "proxy": "minimal stand-in for the hosted Fabric backend", + "action_topic": ACTION_TOPIC, + "result_topic": RESULT_TOPIC, + "robot_id": ROBOT_ID, + "steps": steps, + "simulator_actions_executed": len(results), + } + checks = [ + ("the tunnel refused an unpaid action with HTTP 402", + any(s["step"] == "unpaid" and s["http_status"] == 402 for s in steps)), + ("the tunnel advertised payment requirements", + any(s.get("payment_required_header") for s in steps)), + ("the tunnel refused a forged payment", + any(s["step"] == "forged-payment" and s["http_status"] in (402, 400) for s in steps)), + ("no unpaid or forged action ever reached the simulator", len(results) == 0), + ] + print("\n" + "=" * 70) + print(" INVARIANTS") + print("=" * 70) + for label, ok in checks: + print(f" [{'OK' if ok else '!!'}] {label}") + evidence["invariants"] = {label: ok for label, ok in checks} + evidence["all_invariants_hold"] = all(ok for _, ok in checks) + return evidence + + +def main() -> None: + parser = argparse.ArgumentParser(description="Paid Atlas action through the real Go tunnel.") + parser.add_argument( + "--tunnel", type=Path, required=True, help="Path to the built tunnel binary." + ) + parser.add_argument("--max-duration", type=float, default=8.0) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + if not args.tunnel.is_file(): + raise SystemExit(f"tunnel binary not found: {args.tunnel}") + + evidence = asyncio.run(_run(args.tunnel, args.max_duration)) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f"\n evidence written to {args.json_output}") + raise SystemExit(0 if evidence["all_invariants_hold"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/demo_tunnel.py b/bridge/boston_dynamics/atlas_bridge/demo_tunnel.py new file mode 100644 index 000000000..fc11f27a8 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/demo_tunnel.py @@ -0,0 +1,348 @@ +"""End-to-end payment-validated action over the real Zenoh transport. + +This is the flow the RoboPay README describes, with nothing stubbed between the +payment gate and the simulator:: + + payment-validated action request + -> x402 verification (tunnel side) + -> Zenoh robot/tunnel/action + -> Atlas bridge + -> MuJoCo inspection episode + -> Zenoh robot/tunnel/result + -> correlation by action_id + -> settlement, only on success + +``demo_e2e.py`` covers the same payment invariants in-process and runs in +milliseconds; this module proves the transport itself. It needs +``eclipse-zenoh`` installed and opens a peer-mode session, so no external router +is required. +""" + +from __future__ import annotations + +import argparse +import json +import threading +import time +import uuid +from pathlib import Path + +from .bridge import ACTION_TOPIC, RESULT_TOPIC, ROBOT_ID, AtlasZenohBridge +from .facilitator import FacilitatorClient, payment_requirements +from .payment import SettlementLedger, SettlementStatus +from .task import PAYMENT_NETWORK, SKILL_PRICE_RAW +from .x402 import PaymentPolicy, X402Verifier + +SKILL_ID = "inspect_shelf" +PAYEE = "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" +RESOURCE = "https://robopay.invalid/atlas/inspect_shelf" +#: Duration for each episode in the demo; short so the walkthrough stays quick. +EPISODE_SECONDS = 8.0 +RESULT_TIMEOUT_S = 180.0 + + +def _rejection_status(error) -> SettlementStatus: + """Map an x402 rejection onto the ledger status that actually describes it.""" + from .x402 import X402Error + + if error is X402Error.REPLAY_DETECTED: + return SettlementStatus.SKIPPED_REPLAY + if error is X402Error.EXPIRED: + return SettlementStatus.SKIPPED_EXPIRED + return SettlementStatus.SKIPPED_REJECTED + + +def _receipt(tx_hash: str, amount: str = SKILL_PRICE_RAW) -> dict: + return { + "amount": amount, + "asset": "USDC", + "network": PAYMENT_NETWORK, + "txHash": tx_hash, + } + + +def _envelope(action_id: str, payment: dict | None, params: dict | None = None) -> bytes: + return json.dumps({ + "payload": { + "action": SKILL_ID, + "skill_id": SKILL_ID, + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": f"idem-{action_id}", + "params": params if params is not None else {"maxDurationSec": EPISODE_SECONDS}, + }, + "transaction_details": {"payment_payload": payment} if payment else {}, + "timestamp": "2026-08-19T00:00:00Z", + }).encode("utf-8") + + +class TunnelSide: + """The paying side: verifies x402, publishes, correlates, then settles.""" + + def __init__(self, session, facilitator=None, requirements=None) -> None: + self.verifier = X402Verifier( + PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW), + facilitator=facilitator, + payment_requirements=requirements, + ) + self.verifies_authorization = self.verifier.verifies_authorization + self.ledger = SettlementLedger() + self._results: dict[str, dict] = {} + self._arrived = threading.Event() + self._publisher = session.declare_publisher(ACTION_TOPIC) + self._subscriber = session.declare_subscriber(RESULT_TOPIC, self._on_result) + self.steps: list[dict] = [] + + def _on_result(self, sample) -> None: + envelope = json.loads(bytes(sample.payload.to_bytes()).decode("utf-8")) + self._results[envelope["action_id"]] = envelope + self._arrived.set() + + def _await_result(self, action_id: str, timeout: float) -> dict | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if action_id in self._results: + return self._results[action_id] + self._arrived.wait(0.2) + self._arrived.clear() + return None + + def request(self, name: str, payment: dict | None, params: dict | None = None) -> dict: + """Run one request the whole way through and record what happened.""" + action_id = f"act-{name}-{uuid.uuid4().hex[:8]}" + print(f"\n [{name}] action_id={action_id}") + + verification = self.verifier.verify(payment) + if not verification.valid: + if payment is None: + self.ledger.record_unpaid(action_id, SKILL_ID, ROBOT_ID) + else: + self.ledger.record_rejected( + action_id, SKILL_ID, ROBOT_ID, verification.message, + status=_rejection_status(verification.error), + ) + step = { + "step": name, + "http_status": 402 if payment is None else 400, + "published_to_zenoh": False, + "executed": False, + "settlement_eligible": False, + "settled_on_chain": False, + "error_code": verification.error.value if verification.error else None, + "message": verification.message, + } + print(f" x402 rejected -> HTTP {step['http_status']} ({step['error_code']})") + print(" nothing published to Zenoh, simulator never touched") + self.steps.append(step) + return step + + receipt = verification.receipt + self.ledger.record_execution_start( + action_id=action_id, skill_id=SKILL_ID, robot_id=ROBOT_ID, + tx_hash=receipt.tx_hash, amount=receipt.amount, + asset=receipt.asset, network=receipt.network, + ) + print(f" x402 verified -> publishing on {ACTION_TOPIC}") + self._publisher.put(_envelope(action_id, payment, params)) + result = self._await_result(action_id, RESULT_TIMEOUT_S) + + if result is None: + self.ledger.skip_on_failure(action_id, "No correlated result arrived.") + step = { + "step": name, "http_status": 504, "published_to_zenoh": True, + "executed": False, "settlement_eligible": False, + "settled_on_chain": False, + "error_code": "RESULT_TIMEOUT", "message": "No correlated result arrived.", + } + print(" no correlated result within the timeout") + self.steps.append(step) + return step + + succeeded = result["status"] == "success" + print(f" result correlated on {RESULT_TOPIC}: status={result['status']}") + if succeeded: + inner = result["result"] + print( + f" targets={inner.get('targets_completed')}/{inner.get('targets_total')}" + f" collisions={inner.get('shelf_contacts')} fall={inner.get('fall_detected')}" + ) + + if succeeded: + # No wallet here, so the run becomes eligible for settlement. + # Claiming "settled" would assert a transfer this demo never makes. + self.ledger.settle_on_success(action_id) + self.verifier.record_settlement(receipt.tx_hash, receipt.amount) + print(" settlement eligible (nothing moved on chain)") + else: + self.ledger.skip_on_failure( + action_id, f"Correlated tunnel result reported {result['status']}." + ) + print(" not eligible for settlement") + + step = { + "step": name, + "action_id": action_id, + "http_status": 200, + "published_to_zenoh": True, + "executed": True, + "settlement_eligible": succeeded, + "settled_on_chain": False, + "settlement_tx_hash": None, + "correlation": { + key: result.get(key) + for key in ("action_id", "robot_id", "skill_id", "params_hash", + "idempotency_key", "profile_id") + }, + "result_status": result["status"], + "targets_completed": result["result"].get("targets_completed"), + "targets_total": result["result"].get("targets_total"), + "shelf_contacts": result["result"].get("shelf_contacts"), + "fall_detected": result["result"].get("fall_detected"), + } + self.steps.append(step) + return step + + def close(self) -> None: + self._subscriber.undeclare() + self._publisher.undeclare() + + +def run_demo(json_output: Path | None = None) -> dict: + import zenoh + + print("=" * 68) + print(" Atlas payment-validated action over the real Zenoh transport") + print("=" * 68) + + bridge = AtlasZenohBridge() + print(f" bridge listening on {bridge.action_topic} as {bridge.robot_id}") + + session = zenoh.open(zenoh.Config()) + tunnel = TunnelSide(session) + print( + " payment verification: protocol checks" + + (" + live facilitator" if tunnel.verifies_authorization else " only") + ) + time.sleep(1.5) # let the peers discover each other + + paid_hash = "0x" + "5b" * 32 + try: + # The strongest gate the bridge has: ask the live x402 facilitator to + # verify a structurally perfect but unsigned authorization. Only the + # facilitator can tell the difference, and it must refuse. + forged = { + "x402Version": 1, + "scheme": "exact", + "network": "base-sepolia", + "payload": { + "signature": "0x" + "11" * 65, + "authorization": { + "from": "0x520C3Ff276456A217c0dFadABeEb2d7081d6cCd4", + "to": PAYEE, + "value": SKILL_PRICE_RAW, + "validAfter": "0", + "validBefore": "9999999999", + "nonce": "0x" + "22" * 32, + }, + }, + } + verdict = FacilitatorClient().verify( + forged, payment_requirements(pay_to=PAYEE, resource=RESOURCE) + ) + print("\n [forged-authorization] asking the live x402 facilitator") + print(f" facilitator reachable : {verdict.reachable}") + print(f" isValid : {verdict.is_valid}") + print(f" reason : {verdict.reason or '-'}") + print(" nothing published to Zenoh, simulator never touched") + tunnel.steps.append({ + "step": "forged-authorization", + "http_status": 402, + "published_to_zenoh": False, + "executed": False, + "settlement_eligible": False, + "settled_on_chain": False, + "facilitator_reachable": verdict.reachable, + "facilitator_is_valid": verdict.is_valid, + "facilitator_reason": verdict.reason, + }) + + tunnel.request("unpaid", None) + tunnel.request("wrong-amount", _receipt("0x" + "ab" * 32, amount="500")) + tunnel.request("bad-tx-hash", _receipt("0xnot-a-transaction-hash")) + tunnel.request("paid", _receipt(paid_hash)) + tunnel.request("replay", _receipt(paid_hash)) + tunnel.request("bad-params", _receipt("0x" + "cd" * 32), params={"speedScale": 0.5}) + finally: + tunnel.close() + session.close() + bridge.close() + + ledger = tunnel.ledger.to_dict() + settled = [s for s in tunnel.steps if s.get("settlement_eligible")] + evidence = { + "demo": "atlas_tunnel_e2e", + "transport": "Zenoh (peer mode)", + "action_topic": ACTION_TOPIC, + "result_topic": RESULT_TOPIC, + "robot_id": ROBOT_ID, + "skill_id": SKILL_ID, + "price_raw": SKILL_PRICE_RAW, + "network": PAYMENT_NETWORK, + "steps": tunnel.steps, + "settlement_ledger": ledger, + "payment_verification": ( + "protocol_checks_and_facilitator" if tunnel.verifies_authorization + else "protocol_checks_only" + ), + # Stated next to the steps rather than only in prose, so an artifact + # read on its own cannot be mistaken for proof that value moved. + "settlement": "eligible_not_on_chain", + "settlement_tx_hash": None, + "accepted_receipt": ( + "synthetic; this demo proves the transport and the refusal paths. " + "real-paid-run.json is the artifact where USDC actually moves." + ), + } + + print("\n" + "=" * 68) + print(" INVARIANTS") + print("=" * 68) + checks = [ + ("exactly one request became eligible for settlement", len(settled) == 1), + ("the eligible request is the payment-validated one", + bool(settled) and settled[0]["step"] == "paid"), + ("the payment-validated request completed every target", + bool(settled) and settled[0]["targets_completed"] == settled[0]["targets_total"]), + ("unverified payments never reached Zenoh", + all(not s["published_to_zenoh"] for s in tunnel.steps if not s.get("executed"))), + ("every executed request was correlated by action_id", + all(s["correlation"]["action_id"] == s["action_id"] + for s in tunnel.steps if s.get("executed"))), + ("the live facilitator refused a forged authorization", + any(s["step"] == "forged-authorization" and s["facilitator_is_valid"] is False + for s in tunnel.steps)), + ] + for label, ok in checks: + print(f" [{'OK' if ok else '!!'}] {label}") + evidence["invariants"] = {label: ok for label, ok in checks} + + # Reproducing the walkthrough must not rewrite the committed evidence, so + # the artefact is only written where the caller explicitly asks for it. + if json_output is not None: + json_output.parent.mkdir(parents=True, exist_ok=True) + json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f"\n evidence written to {json_output}") + evidence["all_invariants_hold"] = all(ok for _, ok in checks) + return evidence + + +def main() -> None: + parser = argparse.ArgumentParser(description="Paid Atlas action over real Zenoh.") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + evidence = run_demo(args.json_output) + raise SystemExit(0 if evidence["all_invariants_hold"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/download_atlas_model.py b/bridge/boston_dynamics/atlas_bridge/download_atlas_model.py new file mode 100644 index 000000000..9831898bf --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/download_atlas_model.py @@ -0,0 +1,63 @@ +"""Fetch the pinned Boston Dynamics Atlas v4 description. + +The description is never vendored into this repository: it is checked out from +the upstream commit pinned in ``models/model.lock.json`` (MIT licensed, see +``NOTICE.md``) into a local cache directory that is git-ignored. + +The same URDF feeds MuJoCo, PyBullet and Webots, so every simulator in this +bridge runs one identical robot. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +LOCK_PATH = HERE / "models" / "model.lock.json" +CACHE_DIR = HERE / "models" / "atlas_v4" + + +def _git(*args: str) -> None: + subprocess.run(["git", *args], check=True, stdout=subprocess.DEVNULL) + + +def _checkout(entry: dict, destination: Path) -> None: + """Sparse-checkout ``entry['directory']`` at the pinned commit.""" + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="robopay-atlas-") as temp: + repo = Path(temp) / "source" + _git("clone", "--quiet", "--filter=blob:none", "--no-checkout", entry["source"], str(repo)) + _git("-C", str(repo), "sparse-checkout", "set", entry["directory"]) + _git("-C", str(repo), "checkout", "--quiet", entry["commit"]) + source = repo / entry["directory"] + if not source.is_dir(): + raise RuntimeError(f"Pinned Atlas path missing at {entry['commit']}: {entry['directory']}") + shutil.copytree(source, destination) + + +def download(force: bool = False) -> Path: + """Return the local Atlas description directory, fetching it if needed.""" + entry = json.loads(LOCK_PATH.read_text(encoding="utf-8"))["atlas_v4"] + urdf = CACHE_DIR / entry["urdf"] + if urdf.is_file() and not force: + return CACHE_DIR + if CACHE_DIR.exists(): + shutil.rmtree(CACHE_DIR) + _checkout(entry, CACHE_DIR) + if not urdf.is_file(): + raise RuntimeError(f"Atlas download did not produce {entry['urdf']}") + return CACHE_DIR + + +def urdf_path() -> Path: + """Absolute path to the pinned Atlas v4 URDF, downloading on first use.""" + entry = json.loads(LOCK_PATH.read_text(encoding="utf-8"))["atlas_v4"] + return download() / entry["urdf"] + + +if __name__ == "__main__": + print(f"Atlas v4 URDF: {urdf_path()}") diff --git a/bridge/boston_dynamics/atlas_bridge/episode.py b/bridge/boston_dynamics/atlas_bridge/episode.py new file mode 100644 index 000000000..9bbbb9b40 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/episode.py @@ -0,0 +1,145 @@ +"""Engine-agnostic episode loop and metric reporting. + +Both the MuJoCo and the PyBullet runners call :func:`run_episode`, so the two +engines are scored by exactly the same code and their numbers are comparable +without any per-engine bookkeeping. +""" + +from __future__ import annotations + +import time +from typing import Callable, Protocol + +import numpy as np + +from . import kinematics +from .control_core import POLICY_ID, ShelfInspectionController +from .task import EPISODE_BUDGET_S, FALL_THRESHOLD_M, INSPECTION_TARGETS + +#: The task is only "done" when every target was reached and held. +REQUIRED_TARGETS = len(INSPECTION_TARGETS) + +MODEL_SOURCE = "openai/roboschool @ d32bcb2 — atlas_v4_with_multisense.urdf (MIT)" + + +class InspectionEnvironment(Protocol): + """The surface every simulator backend has to provide.""" + + control_timestep: float + min_pelvis_height: float + max_end_effector_speed: float + shelf_contacts: int + fall_detected: bool + + def joint_limits(self) -> dict[str, tuple[float, float]]: ... + def reset(self, joint_targets: dict[str, float]) -> dict: ... + def step(self, joint_targets: dict[str, float]) -> dict: ... + def safe_stop(self) -> dict: ... + def end_effector(self) -> np.ndarray: ... + def joint_angles(self) -> dict[str, float]: ... + def base_rotation(self) -> np.ndarray: ... + + +def run_episode( + environment: InspectionEnvironment, + engine: str, + max_duration_seconds: float = EPISODE_BUDGET_S, + stop_requested: Callable[[], bool] | None = None, + on_step: Callable[[int, dict, object], None] | None = None, +) -> dict: + """Drive one shelf-inspection episode and return its metrics.""" + controller = ShelfInspectionController(budget_seconds=max_duration_seconds) + observation = environment.reset(controller.reset(environment.joint_limits())) + should_stop = stop_requested or (lambda: False) + + wall_start = time.perf_counter() + control_steps = 0 + safe_stopped = False + plan = None + # The episode maximum is dominated by RETURN, where the controller commands + # the stance pose as a step and only the actuators bound the motion. That + # says nothing about how fast the arm moves near the shelf, so the speed + # reached while reaching and verifying is reported separately. + max_task_phase_speed = 0.0 + previous_hand = None + + while observation["sim_time"] < max_duration_seconds: + if should_stop(): + observation = environment.safe_stop() + safe_stopped = True + break + # The Jacobian comes from the shared URDF kinematics, not from the + # engine, so all simulators drive the arm with identical maths. + angles = environment.joint_angles() + jacobian = kinematics.jacobian(angles, base_rotation=environment.base_rotation()) + plan = controller.step( + environment.end_effector(), jacobian, observation["sim_time"], angles + ) + phase = controller.state.phase + observation = environment.step(plan.joint_targets) + hand = environment.end_effector() + if previous_hand is not None and phase in ("REACH", "VERIFY"): + travelled = float(np.linalg.norm(hand - previous_hand)) + max_task_phase_speed = max( + max_task_phase_speed, travelled / environment.control_timestep + ) + previous_hand = hand + control_steps += 1 + if on_step is not None: + # The plan goes with it so a caller can render the decision the + # controller just made without running its own loop to get at it — + # a second loop would be a second episode. + on_step(control_steps, observation, plan) + if environment.fall_detected or controller.finished: + break + + diagnostics = controller.diagnostics() + completed = diagnostics["targets_completed"] + errors = [entry["final_error_m"] for entry in diagnostics["per_target"] if entry["reached"]] + + if safe_stopped: + completion_reason = "safe_stopped" + elif environment.fall_detected: + completion_reason = "fall" + elif controller.finished: + completion_reason = "sequence_complete" + else: + completion_reason = "time_limit" + + success = ( + completed == REQUIRED_TARGETS + and not environment.fall_detected + and environment.shelf_contacts == 0 + and not safe_stopped + ) + + return { + "simulator_engine": engine, + "robot_model": "Boston Dynamics Atlas v4", + "model_source": MODEL_SOURCE, + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": POLICY_ID, + "status": "success" if success else "failure", + "success": success, + "completion_reason": completion_reason, + "safe_stop_applied": safe_stopped, + "sim_duration_seconds": round(float(observation["sim_time"]), 3), + "wall_time_seconds": round(time.perf_counter() - wall_start, 3), + "control_steps": control_steps, + "targets_total": REQUIRED_TARGETS, + "targets_completed": completed, + "mean_position_error_m": round(sum(errors) / len(errors), 5) if errors else None, + "max_position_error_m": round(max(errors), 5) if errors else None, + "final_pelvis_height_m": round(float(observation["pelvis_height"]), 4), + "min_pelvis_height_m": round(float(environment.min_pelvis_height), 4), + "fall_threshold_m": FALL_THRESHOLD_M, + "fall_detected": environment.fall_detected, + "shelf_contacts": environment.shelf_contacts, + "max_end_effector_speed_mps": round(environment.max_end_effector_speed, 4), + "max_end_effector_speed_inspecting_mps": round(max_task_phase_speed, 4), + "final_torso_roll_rad": round(float(observation["torso_roll"]), 4), + "final_torso_pitch_rad": round(float(observation["torso_pitch"]), 4), + "final_phase": plan.phase if plan else "STAND", + "policy_state": diagnostics, + } diff --git a/bridge/boston_dynamics/atlas_bridge/evidence_recording.py b/bridge/boston_dynamics/atlas_bridge/evidence_recording.py new file mode 100644 index 000000000..85c60ea1c --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/evidence_recording.py @@ -0,0 +1,378 @@ +"""One recording of one paid action, from the 402 to the settlement. + +The profile already has a GIF of the robot and JSON artifacts for the payment, +but a reviewer has to hold the two side by side and trust that they describe the +same run. This records them together instead: a terminal pane that fills in as +the real HTTP exchange happens, next to the simulator rendering the episode that +exchange paid for. + + GET /robots/{id}/skills -> the price, discovered + POST /action, unpaid -> 402 with requirements + sign EIP-3009, nonce = keccak256(action_id) + POST /action, paid -> 202 accepted + Zenoh robot/tunnel/action -> [ the simulator pane runs here ] + Zenoh robot/tunnel/result -> succeeded, 3/3 + GET /action/{id}/status -> settled + settlement + BaseScan link, and the token's own record of the nonce + +It is a single pass. The frames come from the episode the paid action triggered, +because the bridge is given a rendering executor rather than being run twice — +a recording of a second, unpaid episode would not be evidence of the first. + +Usage (the key stays in your shell; it is never printed or written):: + + SETTLEMENT_MNEMONIC="..." python -m bridge.boston_dynamics.atlas_bridge.evidence_recording \\ + --tunnel /path/to/tunnel --output docs/evidence/atlas-paid-action.gif + +``--dry-run`` records discovery and the 402 only, signs nothing, and is enough +to check the layout without spending anything. +""" + +from __future__ import annotations + +import argparse +import json +import os +import threading +import time +from pathlib import Path + +from . import demo_fabric_e2e as flow +from .task import INSPECTION_TARGETS + +SIM_WIDTH, SIM_HEIGHT = 640, 520 +LOG_WIDTH = 560 +FRAME_WIDTH, FRAME_HEIGHT = LOG_WIDTH + SIM_WIDTH, SIM_HEIGHT +#: One rendered frame per this many control steps (2 ms each). +FRAME_STRIDE = 40 +GIF_FRAME_MS = 90 +GIF_PALETTE_COLORS = 96 +#: How long a caption-only step is held, in frames. +HOLD_FRAMES = 9 + +BACKGROUND = (14, 16, 22) +DIM = (120, 132, 148) +TEXT = (226, 232, 240) +OK = (74, 222, 128) +WARN = (250, 204, 21) +LINK = (125, 211, 252) + + +class Transcript: + """The terminal pane: lines appear as the run produces them.""" + + def __init__(self) -> None: + self.lines: list[tuple[str, str, tuple[int, int, int]]] = [] + self._lock = threading.Lock() + + def add(self, tag: str, text: str, colour=TEXT) -> None: + with self._lock: + self.lines.append((tag, text, colour)) + print(f" {tag:<10} {text}", flush=True) + + def snapshot(self) -> list[tuple[str, str, tuple[int, int, int]]]: + with self._lock: + return list(self.lines) + + +def _font(size: int): + from PIL import ImageFont + + for name in ("consola.ttf", "DejaVuSansMono.ttf", "cour.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def _render_log(transcript: Transcript, title: str): + """Draw the terminal pane. The newest lines win if it overflows.""" + from PIL import Image, ImageDraw + + panel = Image.new("RGB", (LOG_WIDTH, FRAME_HEIGHT), BACKGROUND) + draw = ImageDraw.Draw(panel) + header, body = _font(15), _font(13) + + draw.text((16, 14), title, font=header, fill=TEXT) + draw.line([(16, 36), (LOG_WIDTH - 16, 36)], fill=(38, 44, 56), width=1) + + # 18px leaves room for the whole trace, relay line included. At 20px the + # first line scrolled off by the time the settlement arrived, which is the + # one moment a reviewer is most likely to pause on. + lines = transcript.snapshot() + capacity = (FRAME_HEIGHT - 56) // 18 + for index, (tag, text, colour) in enumerate(lines[-capacity:]): + y = 46 + index * 18 + draw.text((16, y), tag, font=body, fill=DIM) + draw.text((16 + 92, y), text, font=body, fill=colour) + return panel + + +def _compose(transcript: Transcript, title: str, sim_frame=None): + from PIL import Image, ImageDraw + + frame = Image.new("RGB", (FRAME_WIDTH, FRAME_HEIGHT), BACKGROUND) + frame.paste(_render_log(transcript, title), (0, 0)) + if sim_frame is not None: + frame.paste(sim_frame, (LOG_WIDTH, 0)) + else: + draw = ImageDraw.Draw(frame) + draw.text((LOG_WIDTH + 190, FRAME_HEIGHT // 2 - 10), + "simulator idle", font=_font(15), fill=DIM) + return frame + + +class Recorder: + """Collects composed frames, and can hold on the current state.""" + + def __init__(self, transcript: Transcript, title: str) -> None: + self.transcript = transcript + self.title = title + self.frames: list = [] + self.latest_sim = None + + def hold(self, count: int = HOLD_FRAMES) -> None: + for _ in range(count): + self.frames.append(_compose(self.transcript, self.title, self.latest_sim)) + + def add_sim(self, sim_frame) -> None: + self.latest_sim = sim_frame + self.frames.append(_compose(self.transcript, self.title, sim_frame)) + + +def rendering_executor(recorder: Recorder): + """An episode runner that renders the episode it is scoring. + + The frames and the metrics come out of one pass. An earlier version drove + its own loop for the frames and then called run_episode again for the + numbers, which produced a recording of one episode beside the metrics of + another — and this profile claims the two describe the same paid action. + Determinism would have made them agree, but agreeing is not the same as + being the same run. Rendering from inside the shared loop's own callback + keeps the claim true by construction rather than by coincidence. + """ + def execute(max_duration_seconds: float, stop_requested=None) -> dict: + import mujoco + from PIL import Image + + from .episode import run_episode + from .mujoco_env import AtlasInspectionEnvironment + from .visual_evidence import _annotate, _camera + + environment = AtlasInspectionEnvironment(show_targets=True) + renderer = mujoco.Renderer(environment.model, height=SIM_HEIGHT, width=SIM_WIDTH) + camera = _camera() + announced: set[str] = set() + + def on_step(control_steps: int, observation: dict, plan) -> None: + if plan.phase not in announced: + announced.add(plan.phase) + recorder.transcript.add("simulator", f"phase {plan.phase}") + if control_steps % FRAME_STRIDE: + return + renderer.update_scene(environment.data, camera) + recorder.add_sim(_annotate( + Image.fromarray(renderer.render()), + [ + ("Atlas v4", "MuJoCo"), + ("phase", plan.phase), + ("target", plan.active_target), + ("error", f"{plan.position_error_m * 1000:6.1f} mm"), + ("completed", f"{plan.targets_completed}/{len(INSPECTION_TARGETS)}"), + ("pelvis", f"{observation['pelvis_height']:.3f} m"), + ("shelf hits", str(environment.shelf_contacts)), + ], + )) + + try: + # One environment, one controller, one episode — scored by the same + # shared loop every other engine is scored by, and rendered from + # inside it. stop_requested reaches this episode, so a safe stop + # halts the run being recorded rather than some other one. + return run_episode( + environment, engine="MuJoCo", + max_duration_seconds=max_duration_seconds, + stop_requested=stop_requested, on_step=on_step, + ) + finally: + renderer.close() + return execute + + +def record(binary: Path, robot_id: str, payee: str, dry_run: bool, + output: Path) -> dict: + """Drive the real hosted-relay flow and compose one recording of it.""" + action_id = f"atlas-inspect-{int(time.time())}" + transcript = Transcript() + recorder = Recorder(transcript, "Atlas — one paid action, end to end") + + os.environ["ROBOT_ID"] = robot_id + os.environ.setdefault("SKILL_CATALOG_PATH", str(flow.PROFILE_DIR / "skill-catalog.json")) + os.environ.setdefault("ROBOT_PROFILE_ID", flow.PROFILE_ID) + + from .bridge import AtlasZenohBridge + + bridge = AtlasZenohBridge(execute=rendering_executor(recorder)) + import tempfile + + workdir = Path(tempfile.mkdtemp(prefix="atlas_record_")) + tunnel = flow.Tunnel(binary, robot_id, payee, workdir) + settlement: dict = {} + try: + transcript.add("relay", flow.FABRIC_API_BASE) + transcript.add("robot", robot_id) + recorder.hold(6) + if not tunnel.wait_until_connected(flow.TUNNEL_CONNECT_TIMEOUT_S): + raise SystemExit("the tunnel never reached the relay") + transcript.add("tunnel", "connected over WSS", OK) + recorder.hold() + + # 1. Discovery — the price is read, not assumed. + status, skills_body, _ = flow._request( + "GET", f"{flow.FABRIC_API_BASE}/robots/{robot_id}/skills") + skills = skills_body.get("skills") or [] + chosen = next((s for s in skills if s.get("skill_id") == flow.SKILL_ID), {}) + price = str(chosen.get("price_usdc") or "") + transcript.add("GET", f"/skills {status} " + ", ".join( + s.get("skill_id", "") for s in skills), OK if status == 200 else WARN) + transcript.add("discovered", f"{flow.SKILL_ID} @ {price} USDC") + recorder.hold() + + action_url = f"{flow.FABRIC_API_BASE}/robots/{robot_id}/action" + body = { + "action": flow.SKILL_ID, "skill_id": flow.SKILL_ID, "robot_id": robot_id, + "action_id": action_id, "idempotency_key": action_id, + "params": {"maxDurationSec": flow.EPISODE_SECONDS}, + } + + # 2. Unpaid — refused by the relay itself. + status, _, headers = flow._request("POST", action_url, body) + requirements = flow._decode_header(flow._header(headers, "PAYMENT-REQUIRED")) + accepted = (requirements.get("accepts") or [{}])[0] + amount = accepted.get("amount") or accepted.get("maxAmountRequired") + transcript.add("POST", f"/action unpaid -> {status}", WARN) + transcript.add("required", f"{amount} raw to {str(accepted.get('payTo',''))[:14]}…") + recorder.hold() + + if dry_run: + transcript.add("dry run", "nothing signed, nothing spent", WARN) + recorder.hold(12) + _write_gif(recorder.frames, output) + return {"dry_run": True, "frames": len(recorder.frames)} + + # 3. Pay for the price that was quoted. + authorization, signature, payer = flow.sign_for(action_id, accepted) + transcript.add("sign", "EIP-3009 authorization") + transcript.add("nonce", "keccak256(action_id)") + transcript.add("payer", payer[:20] + "…") + recorder.hold() + + header = flow.payment_header(authorization, signature, accepted, + int(requirements.get("x402Version") or 1)) + status, paid_body, _ = flow._request( + "POST", action_url, body, {"PAYMENT-SIGNATURE": header}) + transcript.add("POST", f"/action paid -> {status} {paid_body.get('status','')}", + OK if status == 202 else WARN) + transcript.add("zenoh", "robot/tunnel/action published") + recorder.hold() + + # 4. The simulator pane fills in here, from the executor above. + status_url = f"{flow.FABRIC_API_BASE}/robots/{robot_id}/action/{action_id}/status" + deadline = time.monotonic() + flow.STATUS_TIMEOUT_S + terminal = None + while time.monotonic() < deadline: + code, candidate, _ = flow._request("GET", status_url) + if code == 200 and candidate.get("state") in flow.TERMINAL_STATES: + terminal = candidate + if candidate.get("state") == "succeeded": + settle_deadline = time.monotonic() + flow.SETTLEMENT_POLL_S + while time.monotonic() < settle_deadline: + code, candidate, _ = flow._request("GET", status_url) + if code == 200 and (candidate.get("settled") + or candidate.get("settlement_error")): + terminal = candidate + break + time.sleep(2) + break + time.sleep(2) + if terminal is None: + raise SystemExit("no terminal status from the relay") + + result = terminal.get("result") or {} + transcript.add("zenoh", "robot/tunnel/result received", OK) + transcript.add("GET", f"/status -> {terminal.get('state')} " + f"{result.get('targets_completed')}/{result.get('targets_total')}", + OK if terminal.get("state") == "succeeded" else WARN) + transcript.add("correlated", f"action_id {action_id[-12:]}", OK) + recorder.hold() + + # 5. Settlement, and the token's own record of it. + settlement = terminal.get("settlement") or {} + tx_hash = settlement.get("transaction") or "" + if tx_hash: + chain = flow.confirm_on_chain(tx_hash, action_id) + transcript.add("settled", f"{chain['transfer'].get('amount_usdc')} USDC " + f"block {chain['block_number']}", OK) + transcript.add("basescan", f"sepolia.basescan.org/tx/{tx_hash[:18]}…", LINK) + authorization_state = flow.authorization_used_on_chain( + payer, action_id, chain.get("block_number", 0)) + transcript.add("token says", f"authorization spent: {authorization_state['used']}", + OK if authorization_state["used"] else WARN) + transcript.add("bound", "nonce == keccak256(action_id)", + OK if chain["nonce_binds_settlement_to_action"] else WARN) + settlement = {"tx": tx_hash, **chain} + else: + transcript.add("settled", "no — nothing was charged", WARN) + recorder.hold(16) + finally: + tunnel.close() + bridge.close() + + _write_gif(recorder.frames, output) + return { + "action_id": action_id, "robot_id": robot_id, + "frames": len(recorder.frames), "settlement": settlement, + } + + +def _write_gif(frames: list, output: Path) -> None: + from PIL import Image + + if not frames: + raise RuntimeError("no frames were recorded") + output.parent.mkdir(parents=True, exist_ok=True) + palette = frames[0].quantize(colors=GIF_PALETTE_COLORS, method=Image.MEDIANCUT) + quantized = [f.quantize(palette=palette, dither=Image.FLOYDSTEINBERG) for f in frames] + quantized[0].save( + output, save_all=True, append_images=quantized[1:], + duration=GIF_FRAME_MS, loop=0, optimize=True, disposal=2, + ) + size = output.stat().st_size + print(f"\n {output} {len(frames)} frames, {size / 1_048_576:.2f} MB", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Record one paid Atlas action from the 402 to the settlement." + ) + parser.add_argument("--tunnel", type=Path, required=True) + parser.add_argument("--robot-id", default=f"atlas-sim-{int(time.time())}") + parser.add_argument("--payee", default=flow.DEFAULT_PAYEE) + parser.add_argument("--output", type=Path, + default=Path("docs/evidence/atlas-paid-action.gif")) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + if not args.tunnel.is_file(): + raise SystemExit(f"tunnel binary not found: {args.tunnel}") + + summary = record(args.tunnel, args.robot_id, args.payee, args.dry_run, args.output) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/facilitator.py b/bridge/boston_dynamics/atlas_bridge/facilitator.py new file mode 100644 index 000000000..f9c08d714 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/facilitator.py @@ -0,0 +1,120 @@ +"""x402 facilitator verification. + +Checking a receipt's shape is not the same as knowing a payment happened. The +protocol checks in :mod:`x402` catch the wrong amount, the wrong asset, the +wrong network, an expired receipt and a replay — but they cannot tell a real +authorization from a well-formed forgery. Only the facilitator can, because only +it recovers the signer and checks the authorization on chain. + +This client asks the facilitator the one question that matters:: + + POST {facilitator}/verify {paymentPayload, paymentRequirements} + -> {"isValid": true|false, "invalidReason": ...} + +It **fails closed**: a network error, a timeout, a malformed answer or anything +other than an explicit ``isValid: true`` is treated as *not verified*, so a +facilitator that is merely unreachable can never authorise an action. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + +from .task import PAYMENT_NETWORK, SKILL_PRICE_RAW, USDC_BASE_SEPOLIA + +#: The facilitator named by the profile's payment policy. +DEFAULT_FACILITATOR_URL = "https://x402.org/facilitator" +#: The facilitator speaks x402's own network names rather than CAIP-2. +FACILITATOR_NETWORK = "base-sepolia" +REQUEST_TIMEOUT_S = 25.0 + + +@dataclass(frozen=True) +class FacilitatorVerdict: + """What the facilitator said, plus why we are treating it that way.""" + + is_valid: bool + reason: str = "" + payer: str = "" + reachable: bool = True + + @property + def summary(self) -> str: + if self.is_valid: + return "facilitator verified the payment" + if not self.reachable: + return f"facilitator unreachable, failing closed: {self.reason}" + return f"facilitator rejected the payment: {self.reason}" + + +def payment_requirements( + pay_to: str, + resource: str, + amount: str = SKILL_PRICE_RAW, + asset: str = USDC_BASE_SEPOLIA, +) -> dict: + """The requirements half of a verification request, from the profile.""" + return { + "scheme": "exact", + "network": FACILITATOR_NETWORK, + "maxAmountRequired": amount, + "resource": resource, + "description": "Boston Dynamics Atlas shelf inspection", + "mimeType": "application/json", + "payTo": pay_to, + "maxTimeoutSeconds": 60, + "asset": asset, + "extra": {"name": "USDC", "version": "2"}, + } + + +class FacilitatorClient: + """Minimal, fail-closed client for the x402 facilitator's verify endpoint.""" + + def __init__( + self, url: str = DEFAULT_FACILITATOR_URL, timeout: float = REQUEST_TIMEOUT_S + ) -> None: + self.url = url.rstrip("/") + self.timeout = timeout + + def verify(self, payment_payload: dict, requirements: dict) -> FacilitatorVerdict: + body = json.dumps({ + "x402Version": 1, + "paymentPayload": payment_payload, + "paymentRequirements": requirements, + }).encode("utf-8") + request = urllib.request.Request( + f"{self.url}/verify", + data=body, + headers={ + "content-type": "application/json", + "user-agent": "robopay-atlas-bridge/1.0", + }, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + answer = json.loads(response.read()) + except urllib.error.HTTPError as error: + try: + answer = json.loads(error.read()) + except Exception: # noqa: BLE001 - any unreadable body is a rejection + return FacilitatorVerdict(False, f"HTTP {error.code}", reachable=True) + except Exception as error: # noqa: BLE001 - unreachable means not verified + return FacilitatorVerdict(False, str(error), reachable=False) + + if not isinstance(answer, dict): + return FacilitatorVerdict(False, "facilitator returned a non-object") + + return FacilitatorVerdict( + is_valid=answer.get("isValid") is True, + reason=str(answer.get("invalidReason") or answer.get("error") or ""), + payer=str(answer.get("payer") or ""), + ) + + +def network_for(caip2: str = PAYMENT_NETWORK) -> str: + """Map the profile's CAIP-2 network onto the facilitator's own name.""" + return {"eip155:84532": FACILITATOR_NETWORK}.get(caip2, caip2) diff --git a/bridge/boston_dynamics/atlas_bridge/idempotency.py b/bridge/boston_dynamics/atlas_bridge/idempotency.py new file mode 100644 index 000000000..37862a5c7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/idempotency.py @@ -0,0 +1,221 @@ +"""Durable idempotency for payment-validated actions. + +A payment-validated action must actuate the robot **once**. Replay protection on the payment +alone is not enough: the same idempotency key can arrive with a different +payment, and an in-memory guard forgets everything the moment the bridge +restarts — which is exactly when a client is most likely to retry. + +The store is keyed on the tunnel's own identity for the request:: + + robot_id + skill_id + idempotency_key + +and additionally records what that key was first used for, together with how +that first action ended. A repeat of the same key is answered with that recorded +outcome instead of moving the robot; a repeat that changes the parameters or +arrives with a different payment is refused outright, because it is no longer +the same request. + +Records are appended to a JSON-lines file and reloaded on start, so the +guarantee survives a restart. +""" + +from __future__ import annotations + +import json +import threading +from dataclasses import asdict, dataclass +from pathlib import Path + +DEFAULT_STORE_PATH = Path(".robopay/atlas-idempotency.jsonl") + + +@dataclass(frozen=True) +class ActionRecord: + """What a given idempotency key was first used for, and how it ended.""" + + robot_id: str + skill_id: str + idempotency_key: str + params_hash: str + payment_fingerprint: str + action_id: str + status: str + + @property + def key(self) -> str: + return f"{self.robot_id}|{self.skill_id}|{self.idempotency_key}" + + +class ConflictingRequest(ValueError): + """The same idempotency key arrived describing a different request.""" + + def __init__(self, message: str, code: str = "IDEMPOTENCY_CONFLICT") -> None: + super().__init__(message) + self.code = code + + +class IdempotencyStore: + """File-backed store of which actions have already actuated.""" + + def __init__(self, path: Path | str | None = DEFAULT_STORE_PATH) -> None: + self.path = Path(path) if path is not None else None + self._lock = threading.Lock() + self._records: dict[str, ActionRecord] = {} + self._load() + + # -- persistence -------------------------------------------------------- + def _load(self) -> None: + if self.path is None or not self.path.is_file(): + return + for line in self.path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + record = ActionRecord(**json.loads(line)) + except (json.JSONDecodeError, TypeError): + continue # a truncated tail must not take the bridge down + self._records[record.key] = record + + def _append(self, record: ActionRecord) -> None: + if self.path is None: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(asdict(record), sort_keys=True) + "\n") + + # -- guard -------------------------------------------------------------- + def claim( + self, + robot_id: str, + skill_id: str, + idempotency_key: str, + params_hash: str, + payment_fingerprint: str, + action_id: str, + ) -> ActionRecord | None: + """Atomically reserve this key, or report who already holds it. + + Checking and recording must happen under one lock: two concurrent + requests carrying the same key would otherwise both see "new" and both + actuate the robot. Returns ``None`` when the caller now owns the claim, + or the existing record when someone else already does. + """ + if not idempotency_key: + return None + key = f"{robot_id}|{skill_id}|{idempotency_key}" + with self._lock: + record = self._records.get(key) + if record is not None: + self._assert_same_request(record, params_hash, payment_fingerprint) + return record + reservation = ActionRecord( + robot_id=robot_id, + skill_id=skill_id, + idempotency_key=idempotency_key, + params_hash=params_hash, + payment_fingerprint=payment_fingerprint, + action_id=action_id, + status="accepted", + ) + self._records[key] = reservation + self._append(reservation) + return None + + @staticmethod + def _assert_same_request( + record: ActionRecord, params_hash: str, payment_fingerprint: str + ) -> None: + if record.params_hash != params_hash: + raise ConflictingRequest( + f"idempotency key {record.idempotency_key!r} was already used with " + "different parameters", + code="IDEMPOTENCY_PARAMS_CONFLICT", + ) + if record.payment_fingerprint != payment_fingerprint: + raise ConflictingRequest( + f"idempotency key {record.idempotency_key!r} was already used with a " + "different payment", + code="IDEMPOTENCY_PAYMENT_CONFLICT", + ) + + def check( + self, + robot_id: str, + skill_id: str, + idempotency_key: str, + params_hash: str, + payment_fingerprint: str, + ) -> ActionRecord | None: + """Return the earlier record for this key, or None if it is new. + + Raises :class:`ConflictingRequest` when the key was already used for a + materially different request. + """ + if not idempotency_key: + return None + key = f"{robot_id}|{skill_id}|{idempotency_key}" + with self._lock: + record = self._records.get(key) + if record is None: + return None + self._assert_same_request(record, params_hash, payment_fingerprint) + return record + + def remember( + self, + robot_id: str, + skill_id: str, + idempotency_key: str, + params_hash: str, + payment_fingerprint: str, + action_id: str, + status: str, + ) -> ActionRecord | None: + """Record that this key has actuated the robot.""" + if not idempotency_key: + return None + record = ActionRecord( + robot_id=robot_id, + skill_id=skill_id, + idempotency_key=idempotency_key, + params_hash=params_hash, + payment_fingerprint=payment_fingerprint, + action_id=action_id, + status=status, + ) + with self._lock: + self._records[record.key] = record + self._append(record) + return record + + def complete( + self, robot_id: str, skill_id: str, idempotency_key: str, status: str + ) -> ActionRecord | None: + """Record how the claimed action actually ended. + + Without this the store would only ever remember ``accepted``, and a + duplicate could not be answered with the outcome of the run it repeats. + """ + if not idempotency_key: + return None + key = f"{robot_id}|{skill_id}|{idempotency_key}" + with self._lock: + record = self._records.get(key) + if record is None: + return None + finished = ActionRecord( + robot_id=record.robot_id, + skill_id=record.skill_id, + idempotency_key=record.idempotency_key, + params_hash=record.params_hash, + payment_fingerprint=record.payment_fingerprint, + action_id=record.action_id, + status=status, + ) + self._records[key] = finished + self._append(finished) + return finished + + def __len__(self) -> int: + return len(self._records) diff --git a/bridge/boston_dynamics/atlas_bridge/kinematics.py b/bridge/boston_dynamics/atlas_bridge/kinematics.py new file mode 100644 index 000000000..1c255c625 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/kinematics.py @@ -0,0 +1,285 @@ +"""Analytic forward kinematics and Jacobian for the inspection arm. + +The controller needs a Jacobian every control step. MuJoCo and PyBullet can each +supply one, but Webots cannot, and three engine-specific Jacobians would mean the +"same controller" claim quietly stopped being true. + +So the Jacobian is computed here instead, straight from the pinned URDF: same +joint origins, same axes, same chain, in every simulator. Only the *dynamics* +then differ between engines, which is exactly what the sim-to-sim comparison is +meant to measure. ``tests/test_kinematics.py`` checks this against MuJoCo's own +Jacobian so the two can never silently diverge. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from functools import lru_cache + +import numpy as np + +from .download_atlas_model import urdf_path +from .task import END_EFFECTOR_BODY, INSPECTION_CHAIN + + +def _rpy_to_matrix(roll: float, pitch: float, yaw: float) -> np.ndarray: + cr, sr = np.cos(roll), np.sin(roll) + cp, sp = np.cos(pitch), np.sin(pitch) + cy, sy = np.cos(yaw), np.sin(yaw) + return np.array( + [ + [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr], + ] + ) + + +def _axis_angle_to_matrix(axis: np.ndarray, angle: float) -> np.ndarray: + axis = axis / np.linalg.norm(axis) + skew = np.array( + [[0.0, -axis[2], axis[1]], [axis[2], 0.0, -axis[0]], [-axis[1], axis[0], 0.0]] + ) + return np.eye(3) + np.sin(angle) * skew + (1.0 - np.cos(angle)) * (skew @ skew) + + +#: Gravitational acceleration, matching every simulator's world setting. +GRAVITY = 9.81 + + +@dataclass(frozen=True) +class Link: + """One URDF joint expressed as a fixed offset plus a rotation axis.""" + + name: str + parent: str + child: str + origin: np.ndarray + rotation: np.ndarray + axis: np.ndarray + movable: bool + + +@dataclass(frozen=True) +class Inertial: + """A link's mass and centre of mass, in the link's own frame.""" + + mass: float + com: np.ndarray + + +@lru_cache(maxsize=1) +def _joints() -> dict[str, Link]: + root = ET.parse(urdf_path()).getroot() + joints: dict[str, Link] = {} + for joint in root.findall("joint"): + origin = joint.find("origin") + xyz = np.zeros(3) + rpy = np.zeros(3) + if origin is not None: + xyz = np.array([float(v) for v in origin.get("xyz", "0 0 0").split()]) + rpy = np.array([float(v) for v in origin.get("rpy", "0 0 0").split()]) + axis_element = joint.find("axis") + axis = ( + np.array([float(v) for v in axis_element.get("xyz", "1 0 0").split()]) + if axis_element is not None + else np.array([1.0, 0.0, 0.0]) + ) + joints[joint.get("name", "")] = Link( + name=joint.get("name", ""), + parent=joint.find("parent").get("link", ""), + child=joint.find("child").get("link", ""), + origin=xyz, + rotation=_rpy_to_matrix(*rpy), + axis=axis, + movable=joint.get("type") not in (None, "fixed"), + ) + return joints + + +@lru_cache(maxsize=1) +def chain_to_end_effector() -> tuple[Link, ...]: + """Ordered joints from the pelvis down to the end-effector link.""" + joints = _joints() + by_child = {link.child: link for link in joints.values()} + path: list[Link] = [] + cursor = END_EFFECTOR_BODY + while cursor in by_child: + link = by_child[cursor] + path.append(link) + cursor = link.parent + return tuple(reversed(path)) + + +def forward_kinematics(joint_angles: dict[str, float]) -> tuple[np.ndarray, list[tuple[str, np.ndarray, np.ndarray]]]: + """End-effector position in the pelvis frame, plus each joint's frame. + + Returns ``(position, frames)`` where ``frames`` holds + ``(joint_name, world_axis, world_origin)`` for every movable joint on the + chain, which is all the Jacobian needs. + """ + position = np.zeros(3) + rotation = np.eye(3) + frames: list[tuple[str, np.ndarray, np.ndarray]] = [] + for link in chain_to_end_effector(): + position = position + rotation @ link.origin + rotation = rotation @ link.rotation + if link.movable: + axis_world = rotation @ link.axis + frames.append((link.name, axis_world, position.copy())) + rotation = rotation @ _axis_angle_to_matrix(link.axis, joint_angles.get(link.name, 0.0)) + return position, frames + + +def jacobian( + joint_angles: dict[str, float], + chain: tuple[str, ...] = INSPECTION_CHAIN, + base_rotation: np.ndarray | None = None, +) -> np.ndarray: + """Positional Jacobian of the end effector w.r.t. ``chain``. + + ``base_rotation`` rotates the result out of the pelvis frame into the world + frame; pass the pelvis orientation when the robot is free-standing. + """ + position, frames = forward_kinematics(joint_angles) + columns = [] + for name in chain: + match = next((frame for frame in frames if frame[0] == name), None) + if match is None: + raise KeyError(f"{name} is not on the chain to {END_EFFECTOR_BODY}") + _, axis_world, origin = match + columns.append(np.cross(axis_world, position - origin)) + result = np.column_stack(columns) + if base_rotation is not None: + result = base_rotation @ result + return result + + +def end_effector_position( + joint_angles: dict[str, float], + base_position: np.ndarray | None = None, + base_rotation: np.ndarray | None = None, +) -> np.ndarray: + """End-effector position, optionally transformed into the world frame.""" + position, _ = forward_kinematics(joint_angles) + if base_rotation is not None: + position = base_rotation @ position + if base_position is not None: + position = position + base_position + return position + + +@lru_cache(maxsize=1) +def _inertials() -> dict[str, Inertial]: + """Mass and centre of mass of every URDF link, in the link frame.""" + root = ET.parse(urdf_path()).getroot() + inertials: dict[str, Inertial] = {} + for link in root.findall("link"): + inertial = link.find("inertial") + if inertial is None: + continue + mass_element = inertial.find("mass") + origin = inertial.find("origin") + com = ( + np.array([float(v) for v in origin.get("xyz", "0 0 0").split()]) + if origin is not None + else np.zeros(3) + ) + inertials[link.get("name", "")] = Inertial( + mass=float(mass_element.get("value", "0")) if mass_element is not None else 0.0, + com=com, + ) + return inertials + + +@lru_cache(maxsize=1) +def _tree() -> tuple[dict[str, list[Link]], str]: + """Children by parent link, plus the root link name.""" + joints = _joints() + children: dict[str, list[Link]] = {} + for link in joints.values(): + children.setdefault(link.parent, []).append(link) + child_links = {link.child for link in joints.values()} + roots = [name for name in children if name not in child_links] + return children, roots[0] + + +def link_poses(joint_angles: dict[str, float]) -> dict[str, tuple[np.ndarray, np.ndarray]]: + """Position and orientation of every link, in the root (pelvis) frame.""" + children, root = _tree() + poses: dict[str, tuple[np.ndarray, np.ndarray]] = { + root: (np.zeros(3), np.eye(3)) + } + stack = [root] + while stack: + parent = stack.pop() + position, rotation = poses[parent] + for link in children.get(parent, []): + child_position = position + rotation @ link.origin + child_rotation = rotation @ link.rotation + if link.movable: + child_rotation = child_rotation @ _axis_angle_to_matrix( + link.axis, joint_angles.get(link.name, 0.0) + ) + poses[link.child] = (child_position, child_rotation) + stack.append(link.child) + return poses + + +@lru_cache(maxsize=1) +def _subtree_links() -> dict[str, tuple[str, ...]]: + """Every link at or below each joint's child link.""" + children, _ = _tree() + result: dict[str, tuple[str, ...]] = {} + for joint in _joints().values(): + if not joint.movable: + continue + collected: list[str] = [] + stack = [joint.child] + while stack: + current = stack.pop() + collected.append(current) + stack.extend(link.child for link in children.get(current, [])) + result[joint.name] = tuple(collected) + return result + + +def gravity_torques( + joint_angles: dict[str, float], base_rotation: np.ndarray | None = None +) -> dict[str, float]: + """Joint torques that hold the robot against gravity in this configuration. + + Model-based feedforward derived from the pinned URDF's own masses, so every + simulator can run the identical servo law. MuJoCo has ``qfrc_bias``, + PyBullet has inverse dynamics and Webots has neither; computing it here once + keeps the three backends honest about being the same controller. + + ``tests/test_kinematics.py`` checks the result against MuJoCo's own bias + term at rest. + """ + poses = link_poses(joint_angles) + inertials = _inertials() + rotation = np.eye(3) if base_rotation is None else np.asarray(base_rotation) + gravity = rotation.T @ np.array([0.0, 0.0, -GRAVITY]) + + torques: dict[str, float] = {} + for joint in _joints().values(): + if not joint.movable or joint.child not in poses: + continue + joint_position, joint_rotation = poses[joint.child] + # The joint frame's axis before its own rotation is applied is the same + # axis expressed in the child frame, so use the child pose directly. + axis_world = joint_rotation @ joint.axis + torque = 0.0 + for link_name in _subtree_links()[joint.name]: + inertial = inertials.get(link_name) + if inertial is None or inertial.mass == 0.0: + continue + position, link_rotation = poses[link_name] + com_world = position + link_rotation @ inertial.com + force = inertial.mass * gravity + lever = com_world - joint_position + torque += float(np.dot(axis_world, np.cross(lever, force))) + torques[joint.name] = -torque + return torques diff --git a/bridge/boston_dynamics/atlas_bridge/model.py b/bridge/boston_dynamics/atlas_bridge/model.py new file mode 100644 index 000000000..2bb7cc621 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/model.py @@ -0,0 +1,125 @@ +"""Build the Atlas v4 MJCF used by the MuJoCo side of the bridge. + +The MJCF is generated from the pinned upstream URDF (see ``NOTICE.md``) so the +robot, its joint limits and its actuator efforts always come from one source of +truth. Nothing about the robot is hand-transcribed here: + +* joint effort limits become motor ``gear`` values, read out of the URDF; +* the actuator set is generated from the URDF joints, in URDF order; +* only simulation-side details the URDF cannot express (free base, joint + armature, ground plane) are added by this module. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +import mujoco + +from .download_atlas_model import urdf_path + +# Rotor inertia reflected through the gearbox. The URDF has no armature and a +# 182 kg humanoid on stiff joint servos is numerically stiff without it. +JOINT_ARMATURE = 0.05 +# Pelvis spawn height; the environment drops the robot onto its soles from here. +SPAWN_HEIGHT_M = 1.20 + +_MUJOCO_COMPILER = ( + '' +) + + +def joint_efforts() -> dict[str, float]: + """Map every actuated Atlas joint to its URDF effort limit in N·m.""" + root = ET.parse(urdf_path()).getroot() + efforts: dict[str, float] = {} + for joint in root.findall("joint"): + if joint.get("type") in (None, "fixed"): + continue + limit = joint.find("limit") + if limit is None or limit.get("effort") is None: + continue + efforts[joint.get("name", "")] = float(limit.get("effort", "0")) + return efforts + + +def physics_urdf() -> Path: + """Write and return a physics-only copy of the pinned Atlas URDF. + + ```` elements are dropped because the upstream description points + them at COLLADA meshes from a sibling ROS package, while all collision + geometry is analytic (boxes, cylinders, spheres). Removing them lets MuJoCo, + PyBullet and Webots consume the *same* file with no per-engine asset + handling, which is what makes the three runs one robot. + """ + source = urdf_path() + staged = source.with_name("atlas_v4_physics.urdf") + tree = ET.parse(source) + root = tree.getroot() + for link in root.iter("link"): + for visual in link.findall("visual"): + link.remove(visual) + tree.write(staged, encoding="unicode", xml_declaration=True) + return staged + + +def _mjcf_from_urdf() -> str: + """Import the pinned URDF through MuJoCo and return it as MJCF text.""" + source = physics_urdf().read_text(encoding="utf-8") + patched = re.sub(r"(]*>)", r"\1\n" + _MUJOCO_COMPILER, source, count=1) + staged = urdf_path().with_name("_robopay_mujoco.urdf") + staged.write_text(patched, encoding="utf-8") + try: + model = mujoco.MjModel.from_xml_path(str(staged)) + target = staged.with_suffix(".xml") + mujoco.mj_saveLastXML(str(target), model) + return target.read_text(encoding="utf-8") + finally: + staged.unlink(missing_ok=True) + + +def scene_xml(free_base: bool = True) -> str: + """Return the complete MJCF scene: Atlas v4 plus ground plane and motors. + + ``free_base=True`` gives the free-standing robot used for the bounty task. + ``free_base=False`` welds the pelvis and is used only by component tests. + """ + xml = _mjcf_from_urdf() + + if free_base: + xml = xml.replace( + '', + f'\n', + 1, + ) + xml = re.sub( + r"(]*\barmature=)[^>]*?)/>", + rf'\1 armature="{JOINT_ARMATURE}"/>', + xml, + ) + + motors = "".join( + f' \n' + for name, effort in joint_efforts().items() + ) + extras = ( + # Large enough offscreen buffer for the evidence renderer. + ' \n \n \n' + f" \n{motors} \n" + ' \n' + ' \n' + ' \n' + ' \n' + " \n" + ) + return xml.replace("", extras + "") + + +def write_scene(destination: Path, free_base: bool = True) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(scene_xml(free_base=free_base), encoding="utf-8") + return destination diff --git a/bridge/boston_dynamics/atlas_bridge/models/model.lock.json b/bridge/boston_dynamics/atlas_bridge/models/model.lock.json new file mode 100644 index 000000000..083f67796 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/models/model.lock.json @@ -0,0 +1,17 @@ +{ + "atlas_v4": { + "robot": "Boston Dynamics Atlas v4 (with Multisense head)", + "source": "https://github.com/openai/roboschool", + "commit": "d32bcb2b35b94168b5ce27233ca62f3c8678886f", + "directory": "roboschool/models_robot/atlas_description", + "urdf": "urdf/atlas_v4_with_multisense.urdf", + "license": "MIT", + "license_file": "LICENSE.md", + "notes": [ + "Collision geometry in this URDF is analytic (boxes/cylinders/spheres) only,", + "so no mesh assets are needed for physics and none are vendored into this repo.", + "MuJoCo imports the URDF with discardvisual=true; PyBullet and Webots consume", + "the same pinned URDF, so all three simulators run one identical robot." + ] + } +} diff --git a/bridge/boston_dynamics/atlas_bridge/mujoco_env.py b/bridge/boston_dynamics/atlas_bridge/mujoco_env.py new file mode 100644 index 000000000..532c6021e --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/mujoco_env.py @@ -0,0 +1,214 @@ +"""MuJoCo environment for the Atlas shelf-inspection task.""" + +from __future__ import annotations + +import math + +import mujoco +import numpy as np + +from . import actuators as actuator_map +from . import kinematics +from .model import SPAWN_HEIGHT_M, joint_efforts, scene_xml +from .task import ( + END_EFFECTOR_BODY, + FALL_THRESHOLD_M, + INSPECTION_CHAIN, + INSPECTION_TARGETS, + SERVO_KD, + SERVO_KP, + SHELF_PARTS, + STANCE_POSE, +) + +#: Clearance added after dropping the robot so the soles start just off the floor. +SPAWN_CLEARANCE_M = 0.002 + + +def _quaternion_to_rpy(quat: np.ndarray) -> tuple[float, float]: + w, x, y, z = quat + roll = math.atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)) + pitch = math.asin(max(-1.0, min(1.0, 2.0 * (w * y - z * x)))) + return roll, pitch + + +class AtlasInspectionEnvironment: + """Free-standing Atlas v4 in front of an inspection shelf. + + The robot is never welded, clamped or otherwise held up: it stands on its own + soles for the whole episode and the fall check in :attr:`fall_detected` uses + the real standing height, not floor contact. + """ + + def __init__(self, show_targets: bool = False) -> None: + """``show_targets`` adds non-colliding markers used only by the renderer. + + The markers carry ``contype=0`` and ``conaffinity=0`` and sit on bodies + with no joint, so they cannot influence the simulation. The evidence + renderer asserts that an episode with markers produces exactly the same + metrics as one without. + """ + spec = mujoco.MjSpec.from_string(scene_xml(free_base=True)) + for part in SHELF_PARTS: + body = spec.worldbody.add_body(name=part["name"], pos=list(part["pos"])) + body.add_geom( + name=f"{part['name']}_geom", + type=mujoco.mjtGeom.mjGEOM_BOX, + size=list(part["half"]), + rgba=[0.55, 0.42, 0.28, 1.0], + ) + if show_targets: + for target in INSPECTION_TARGETS: + marker = spec.worldbody.add_body( + name=f"marker_{target.name}", pos=list(target.position) + ) + marker.add_geom( + name=f"marker_{target.name}_geom", + type=mujoco.mjtGeom.mjGEOM_SPHERE, + size=[target.tolerance_m, 0.0, 0.0], + rgba=[0.10, 0.85, 0.45, 0.35], + contype=0, + conaffinity=0, + ) + + self.model = spec.compile() + self.data = mujoco.MjData(self.model) + + self.actuators = actuator_map.validate(self.model, joint_efforts()) + self.pelvis_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "pelvis") + self.hand_id = mujoco.mj_name2id( + self.model, mujoco.mjtObj.mjOBJ_BODY, END_EFFECTOR_BODY + ) + self._chain_dofs = [ + int(self.model.jnt_dofadr[self.model.actuator_trnid[self.actuators.index(joint), 0]]) + for joint in INSPECTION_CHAIN + ] + self._shelf_geoms = { + mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, f"{part['name']}_geom") + for part in SHELF_PARTS + } + self._floor_geom = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "floor") + self._jacp = np.zeros((3, self.model.nv)) + + self.control_timestep = float(self.model.opt.timestep) + self.min_pelvis_height = math.inf + self.max_end_effector_speed = 0.0 + self.shelf_contacts = 0 + self.fall_detected = False + + # -- episode ----------------------------------------------------------- + def joint_limits(self) -> dict[str, tuple[float, float]]: + limits: dict[str, tuple[float, float]] = {} + for name in self.actuators.names: + joint = self.model.joint(name) + low, high = (float(joint.range[0]), float(joint.range[1])) + limits[name] = (low, high) if joint.limited[0] else (-math.pi, math.pi) + return limits + + def reset(self, joint_targets: dict[str, float]) -> dict: + """Place Atlas standing on its soles in the requested pose.""" + mujoco.mj_resetData(self.model, self.data) + pose = self.actuators.vector({**STANCE_POSE, **joint_targets}) + self.data.qpos[3] = 1.0 + self.data.qpos[self.actuators.qpos_addresses] = pose + self.data.qpos[2] = SPAWN_HEIGHT_M + mujoco.mj_forward(self.model, self.data) + self.data.qpos[2] = SPAWN_HEIGHT_M - self._lowest_point() + SPAWN_CLEARANCE_M + mujoco.mj_forward(self.model, self.data) + + self.min_pelvis_height = float(self.data.xpos[self.pelvis_id][2]) + self.max_end_effector_speed = 0.0 + self.shelf_contacts = 0 + self.fall_detected = False + self._previous_hand = None + return self.observe() + + def _lowest_point(self) -> float: + return min( + float(self.data.geom_xpos[i][2] - self.model.geom_size[i][2]) + for i in range(self.model.ngeom) + if self.model.geom_bodyid[i] != 0 + and self.model.geom_type[i] == mujoco.mjtGeom.mjGEOM_BOX + ) + + def step(self, joint_targets: dict[str, float]) -> dict: + """Apply one servo step towards ``joint_targets`` and advance physics.""" + desired = self.actuators.vector(joint_targets) + positions = self.data.qpos[self.actuators.qpos_addresses] + velocities = self.data.qvel[self.actuators.qvel_addresses] + # Gravity feedforward removes the steady-state droop a pure PD servo + # leaves under load; without it Atlas slowly yields at the ankles while + # holding an extended arm and eventually topples. The term comes from + # the shared URDF model rather than from MuJoCo, so every engine runs the + # same maths; test_gravity_model_matches_mujoco pins the two together. + gravity = self.actuators.vector( + kinematics.gravity_torques(self.joint_angles(), self.base_rotation()) + ) + command = SERVO_KP * (desired - positions) - SERVO_KD * velocities + gravity + self.data.ctrl[:] = np.clip(command / self.actuators.effort_limits, -1.0, 1.0) + mujoco.mj_step(self.model, self.data) + return self.observe() + + def safe_stop(self) -> dict: + """Zero every actuator and freeze the robot, as required by the tunnel.""" + self.data.ctrl[:] = 0.0 + self.data.qvel[:] = 0.0 + mujoco.mj_forward(self.model, self.data) + return self.observe() + + # -- measurement ------------------------------------------------------- + def end_effector(self) -> np.ndarray: + return self.data.xpos[self.hand_id].copy() + + def joint_angles(self) -> dict[str, float]: + """Measured joint positions, keyed by Atlas joint name.""" + return { + name: float(self.data.qpos[address]) + for name, address in zip(self.actuators.names, self.actuators.qpos_addresses) + } + + def base_rotation(self) -> np.ndarray: + """Pelvis orientation, used to lift the arm Jacobian into world frame.""" + return self.data.xmat[self.pelvis_id].reshape(3, 3).copy() + + def engine_jacobian(self) -> np.ndarray: + """MuJoCo's own Jacobian, kept only so tests can cross-check ours.""" + mujoco.mj_jacBody(self.model, self.data, self._jacp, None, self.hand_id) + return self._jacp[:, self._chain_dofs].copy() + + def observe(self) -> dict: + pelvis_height = float(self.data.xpos[self.pelvis_id][2]) + self.min_pelvis_height = min(self.min_pelvis_height, pelvis_height) + if pelvis_height < FALL_THRESHOLD_M: + self.fall_detected = True + + contacts = 0 + for i in range(self.data.ncon): + pair = {self.data.contact[i].geom1, self.data.contact[i].geom2} + if pair & self._shelf_geoms: + contacts += 1 + self.shelf_contacts += contacts + + # Measured as the distance the hand actually travelled this control + # step. MuJoCo's cvel is [angular; linear] in a com-based frame, so + # reading cvel[:3] reported rad/s as m/s; a finite difference is both + # correct and identical to how the other two engines measure it. + hand = self.end_effector() + speed = 0.0 + if self._previous_hand is not None: + speed = float(np.linalg.norm(hand - self._previous_hand)) / self.model.opt.timestep + self._previous_hand = hand + self.max_end_effector_speed = max(self.max_end_effector_speed, speed) + roll, pitch = _quaternion_to_rpy(self.data.qpos[3:7]) + + return { + "sim_time": float(self.data.time), + "pelvis_height": pelvis_height, + "pelvis_position": self.data.xpos[self.pelvis_id].copy(), + "torso_roll": roll, + "torso_pitch": pitch, + "end_effector": self.end_effector(), + "end_effector_speed": speed, + "shelf_contacts_step": contacts, + "upright": pelvis_height >= FALL_THRESHOLD_M, + } diff --git a/bridge/boston_dynamics/atlas_bridge/payment.py b/bridge/boston_dynamics/atlas_bridge/payment.py new file mode 100644 index 000000000..55919c5a8 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/payment.py @@ -0,0 +1,226 @@ +"""Payment settlement ledger for x402 flow. + +Tracks settlement decisions: settle on success, no-settle on failure. +Provides audit trail for payment safety compliance. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + + +class SettlementStatus(Enum): + PENDING = "PENDING" + #: A real on-chain transaction moved the money. Nothing else earns this. + SETTLED = "SETTLED" + #: The execution succeeded and the policy authorises payment, but this run + #: put nothing on chain. Distinct from SETTLED because an artifact that + #: calls a protocol-level demo "SETTLED" is claiming a transfer that never + #: happened, and a reviewer reading the artifact alone cannot tell. + SETTLEMENT_ELIGIBLE = "SETTLEMENT_ELIGIBLE" + SKIPPED_FAILURE = "SKIPPED_FAILURE" + SKIPPED_UNPAID = "SKIPPED_UNPAID" + SKIPPED_REPLAY = "SKIPPED_REPLAY" + SKIPPED_REJECTED = "SKIPPED_REJECTED" + SKIPPED_EXPIRED = "SKIPPED_EXPIRED" + + +@dataclass +class SettlementEntry: + action_id: str + skill_id: str + robot_id: str + status: SettlementStatus + #: The payment receipt presented by the caller — an input, not a transfer. + tx_hash: str = "" + #: The on-chain settlement transaction, if one was actually made. + settlement_tx_hash: str = "" + amount: str = "" + asset: str = "" + network: str = "" + block_number: int = 0 + timestamp: float = field(default_factory=time.time) + reason: str = "" + execution_success: bool = False + + +class SettlementLedger: + """Tracks payment settlement decisions for audit trail. + + Core invariant: settlement occurs ONLY on successful execution. + Failed/timed-out/rejected actions must NOT settle. + """ + + def __init__(self) -> None: + self._entries: list[SettlementEntry] = [] + self._action_settled: set[str] = set() + + def record_unpaid(self, action_id: str, skill_id: str, robot_id: str) -> SettlementEntry: + entry = SettlementEntry( + action_id=action_id, + skill_id=skill_id, + robot_id=robot_id, + status=SettlementStatus.SKIPPED_UNPAID, + reason="No payment provided. HTTP 402 returned.", + ) + self._entries.append(entry) + return entry + + def record_rejected( + self, + action_id: str, + skill_id: str, + robot_id: str, + reason: str, + status: SettlementStatus = SettlementStatus.SKIPPED_REJECTED, + ) -> SettlementEntry: + """Record a payment the gate refused. + + The caller passes the status that actually applies. Labelling every + rejection a replay — as an earlier revision did — made a wrong amount + read as a replayed receipt in the audit trail. + """ + entry = SettlementEntry( + action_id=action_id, + skill_id=skill_id, + robot_id=robot_id, + status=status, + reason=reason, + ) + self._entries.append(entry) + return entry + + def record_execution_start( + self, + action_id: str, + skill_id: str, + robot_id: str, + tx_hash: str, + amount: str, + asset: str, + network: str, + ) -> SettlementEntry: + entry = SettlementEntry( + action_id=action_id, + skill_id=skill_id, + robot_id=robot_id, + status=SettlementStatus.PENDING, + tx_hash=tx_hash, + amount=amount, + asset=asset, + network=network, + ) + self._entries.append(entry) + return entry + + def settle_on_success( + self, + action_id: str, + block_number: int = 0, + settlement_tx_hash: str = "", + ) -> SettlementEntry | None: + """Mark a successful execution as paid for. + + ``SETTLED`` requires a real settlement transaction — its hash and the + block that contains it. Without one the entry becomes + ``SETTLEMENT_ELIGIBLE``: the execution succeeded and the policy would + pay, but no value moved, and the ledger says so rather than publishing + a transfer that did not happen. The payment receipt that authorised the + run is not a settlement transaction and never fills this in. + """ + if action_id in self._action_settled: + return self._find_entry(action_id) + entry = self._find_pending(action_id) + if entry is None: + return None + entry.execution_success = True + if settlement_tx_hash and block_number: + entry.status = SettlementStatus.SETTLED + entry.settlement_tx_hash = settlement_tx_hash + entry.block_number = block_number + entry.reason = "Execution succeeded. Settled on chain." + else: + entry.status = SettlementStatus.SETTLEMENT_ELIGIBLE + entry.settlement_tx_hash = "" + entry.block_number = 0 + entry.reason = ( + "Execution succeeded and settlement is authorised by policy. " + "No on-chain transaction was made in this run." + ) + self._action_settled.add(action_id) + return entry + + def skip_on_failure( + self, + action_id: str, + reason: str = "Execution failed. No settlement.", + ) -> SettlementEntry | None: + entry = self._find_pending(action_id) + if entry is None: + return None + entry.status = SettlementStatus.SKIPPED_FAILURE + entry.execution_success = False + entry.reason = reason + return entry + + def _find_pending(self, action_id: str) -> SettlementEntry | None: + for entry in reversed(self._entries): + if entry.action_id == action_id and entry.status == SettlementStatus.PENDING: + return entry + return None + + def _find_entry(self, action_id: str) -> SettlementEntry | None: + for entry in reversed(self._entries): + if entry.action_id == action_id: + return entry + return None + + def get_entry(self, action_id: str) -> SettlementEntry | None: + for entry in reversed(self._entries): + if entry.action_id == action_id: + return entry + return None + + def get_all(self) -> list[SettlementEntry]: + return list(self._entries) + + def to_dict(self) -> dict: + return { + "entries": [ + { + "action_id": e.action_id, + "skill_id": e.skill_id, + "robot_id": e.robot_id, + "status": e.status.value, + "receipt_tx_hash": e.tx_hash, + "settlement_tx_hash": e.settlement_tx_hash or None, + "settled_on_chain": bool(e.settlement_tx_hash), + "amount": e.amount, + "asset": e.asset, + "network": e.network, + "block_number": e.block_number, + "timestamp": e.timestamp, + "reason": e.reason, + "execution_success": e.execution_success, + } + for e in self._entries + ], + "total": len(self._entries), + "settled_on_chain": sum( + 1 for e in self._entries if e.status == SettlementStatus.SETTLED + ), + "settlement_eligible_not_on_chain": sum( + 1 for e in self._entries + if e.status == SettlementStatus.SETTLEMENT_ELIGIBLE + ), + "skipped_failure": sum(1 for e in self._entries if e.status == SettlementStatus.SKIPPED_FAILURE), + "skipped_unpaid": sum(1 for e in self._entries if e.status == SettlementStatus.SKIPPED_UNPAID), + } + + def save(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8") diff --git a/bridge/boston_dynamics/atlas_bridge/pybullet_env.py b/bridge/boston_dynamics/atlas_bridge/pybullet_env.py new file mode 100644 index 000000000..1044ce10d --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/pybullet_env.py @@ -0,0 +1,233 @@ +"""PyBullet environment for the Atlas shelf-inspection task. + +This is the sim-to-sim counterpart of :mod:`mujoco_env`. It loads the *same* +pinned Atlas v4 URDF, builds the *same* shelf from :mod:`task`, and is driven by +the *same* :class:`~.control_core.ShelfInspectionController`, so a metric +difference between the two runs is a physics-engine difference and nothing else. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pybullet +import pybullet_data + +from .model import physics_urdf +from .task import ( + END_EFFECTOR_BODY, + FALL_THRESHOLD_M, + INSPECTION_CHAIN, + SHELF_PARTS, + STANCE_POSE, +) + +#: Matches ``mujoco_env`` so both engines integrate at the same rate. +TIME_STEP_S = 1.0 / 500.0 +SPAWN_HEIGHT_M = 1.20 +SPAWN_CLEARANCE_M = 0.002 + + +class AtlasInspectionPyBulletEnvironment: + """Free-standing Atlas v4 in PyBullet, same task geometry as MuJoCo.""" + + def __init__(self, gui: bool = False) -> None: + self.client = pybullet.connect(pybullet.GUI if gui else pybullet.DIRECT) + pybullet.setAdditionalSearchPath(pybullet_data.getDataPath(), physicsClientId=self.client) + pybullet.setGravity(0, 0, -9.81, physicsClientId=self.client) + pybullet.setTimeStep(TIME_STEP_S, physicsClientId=self.client) + pybullet.loadURDF("plane.urdf", physicsClientId=self.client) + + self.robot = pybullet.loadURDF( + str(physics_urdf()), [0, 0, SPAWN_HEIGHT_M], useFixedBase=False, + physicsClientId=self.client, + ) + self.joint_indices: dict[str, int] = {} + self.link_indices: dict[str, int] = {} + for index in range(pybullet.getNumJoints(self.robot, physicsClientId=self.client)): + info = pybullet.getJointInfo(self.robot, index, physicsClientId=self.client) + self.link_indices[info[12].decode()] = index + if info[2] != pybullet.JOINT_FIXED: + self.joint_indices[info[1].decode()] = index + self.names = tuple(self.joint_indices) + self.effort_limits = np.array( + [ + pybullet.getJointInfo(self.robot, self.joint_indices[name], + physicsClientId=self.client)[10] + for name in self.names + ], + dtype=np.float64, + ) + self.hand_index = self.link_indices[END_EFFECTOR_BODY] + self._chain_slots = [self.names.index(joint) for joint in INSPECTION_CHAIN] + + # Release the implicit velocity motors PyBullet attaches to every joint, + # so the only thing driving Atlas is this bridge's own servo command. + for index in self.joint_indices.values(): + pybullet.setJointMotorControl2( + self.robot, index, pybullet.VELOCITY_CONTROL, force=0.0, + physicsClientId=self.client, + ) + + self.shelf_ids: list[int] = [] + for part in SHELF_PARTS: + shape = pybullet.createCollisionShape( + pybullet.GEOM_BOX, halfExtents=list(part["half"]), + physicsClientId=self.client, + ) + self.shelf_ids.append( + pybullet.createMultiBody( + baseMass=0.0, baseCollisionShapeIndex=shape, + basePosition=list(part["pos"]), physicsClientId=self.client, + ) + ) + + self.min_pelvis_height = math.inf + self.max_end_effector_speed = 0.0 + self.control_timestep = TIME_STEP_S + self.shelf_contacts = 0 + self.fall_detected = False + self._previous_hand: np.ndarray | None = None + self._time = 0.0 + + # -- episode ----------------------------------------------------------- + def joint_limits(self) -> dict[str, tuple[float, float]]: + limits: dict[str, tuple[float, float]] = {} + for name, index in self.joint_indices.items(): + info = pybullet.getJointInfo(self.robot, index, physicsClientId=self.client) + low, high = info[8], info[9] + limits[name] = (low, high) if low < high else (-math.pi, math.pi) + return limits + + def _vector(self, targets: dict[str, float]) -> np.ndarray: + return np.array([targets.get(name, 0.0) for name in self.names], dtype=np.float64) + + def reset(self, joint_targets: dict[str, float]) -> dict: + pose = self._vector({**STANCE_POSE, **joint_targets}) + pybullet.resetBasePositionAndOrientation( + self.robot, [0, 0, SPAWN_HEIGHT_M], [0, 0, 0, 1], physicsClientId=self.client + ) + for value, name in zip(pose, self.names): + pybullet.resetJointState( + self.robot, self.joint_indices[name], float(value), 0.0, + physicsClientId=self.client, + ) + lowest = min( + pybullet.getAABB(self.robot, index, physicsClientId=self.client)[0][2] + for index in range(-1, pybullet.getNumJoints(self.robot, physicsClientId=self.client)) + ) + pybullet.resetBasePositionAndOrientation( + self.robot, + [0, 0, SPAWN_HEIGHT_M - lowest + SPAWN_CLEARANCE_M], + [0, 0, 0, 1], + physicsClientId=self.client, + ) + self.min_pelvis_height = self._pelvis_height() + self.max_end_effector_speed = 0.0 + self.shelf_contacts = 0 + self.fall_detected = False + self._previous_hand = None + self._time = 0.0 + return self.observe() + + def step(self, joint_targets: dict[str, float]) -> dict: + """Servo towards ``joint_targets`` and advance one physics step. + + MuJoCo runs an explicit PD law with a gravity feedforward; PyBullet uses + its own implicit joint servo, saturated at the same URDF effort limits. + An explicit PD at these gains is numerically unstable at PyBullet's fixed + step, so the *servo implementation* differs by engine while the task, the + robot, the state machine and the IK stay identical — that difference is + exactly what the sim-to-sim comparison is there to bound. + """ + desired = self._vector(joint_targets) + pybullet.setJointMotorControlArray( + self.robot, + list(self.joint_indices.values()), + pybullet.POSITION_CONTROL, + targetPositions=desired.tolist(), + forces=self.effort_limits.tolist(), + physicsClientId=self.client, + ) + pybullet.stepSimulation(physicsClientId=self.client) + self._time += TIME_STEP_S + return self.observe() + + def safe_stop(self) -> dict: + pybullet.setJointMotorControlArray( + self.robot, list(self.joint_indices.values()), pybullet.TORQUE_CONTROL, + forces=[0.0] * len(self.names), physicsClientId=self.client, + ) + return self.observe() + + # -- measurement ------------------------------------------------------- + def _pelvis_height(self) -> float: + return float( + pybullet.getBasePositionAndOrientation(self.robot, physicsClientId=self.client)[0][2] + ) + + def end_effector(self) -> np.ndarray: + state = pybullet.getLinkState( + self.robot, self.hand_index, computeLinkVelocity=1, + computeForwardKinematics=1, physicsClientId=self.client, + ) + return np.array(state[0], dtype=np.float64) + + def joint_angles(self) -> dict[str, float]: + """Measured joint positions, keyed by Atlas joint name.""" + states = pybullet.getJointStates( + self.robot, list(self.joint_indices.values()), physicsClientId=self.client + ) + return {name: float(state[0]) for name, state in zip(self.names, states)} + + def base_rotation(self) -> np.ndarray: + _, orientation = pybullet.getBasePositionAndOrientation( + self.robot, physicsClientId=self.client + ) + return np.array( + pybullet.getMatrixFromQuaternion(orientation), dtype=np.float64 + ).reshape(3, 3) + + def observe(self) -> dict: + position, orientation = pybullet.getBasePositionAndOrientation( + self.robot, physicsClientId=self.client + ) + height = float(position[2]) + self.min_pelvis_height = min(self.min_pelvis_height, height) + if height < FALL_THRESHOLD_M: + self.fall_detected = True + + contacts = 0 + for shelf in self.shelf_ids: + contacts += len( + pybullet.getContactPoints( + bodyA=self.robot, bodyB=shelf, physicsClientId=self.client + ) + ) + self.shelf_contacts += contacts + + # Same finite difference as the other engines, so the three reported + # speeds are measured identically. + hand = self.end_effector() + speed = 0.0 + if self._previous_hand is not None: + speed = float(np.linalg.norm(hand - self._previous_hand)) / TIME_STEP_S + self._previous_hand = hand + self.max_end_effector_speed = max(self.max_end_effector_speed, speed) + roll, pitch, _ = pybullet.getEulerFromQuaternion(orientation) + + return { + "sim_time": self._time, + "pelvis_height": height, + "pelvis_position": np.array(position, dtype=np.float64), + "torso_roll": float(roll), + "torso_pitch": float(pitch), + "end_effector": self.end_effector(), + "end_effector_speed": speed, + "shelf_contacts_step": contacts, + "upright": height >= FALL_THRESHOLD_M, + } + + def close(self) -> None: + pybullet.disconnect(physicsClientId=self.client) diff --git a/bridge/boston_dynamics/atlas_bridge/pybullet_runner.py b/bridge/boston_dynamics/atlas_bridge/pybullet_runner.py new file mode 100644 index 000000000..1571361f7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/pybullet_runner.py @@ -0,0 +1,42 @@ +"""Run one Atlas shelf-inspection episode in PyBullet.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .episode import run_episode +from .pybullet_env import AtlasInspectionPyBulletEnvironment +from .task import EPISODE_BUDGET_S + + +def run_inspection(max_duration_seconds: float = EPISODE_BUDGET_S, gui: bool = False) -> dict: + """Execute the inspection skill in PyBullet and return its metrics.""" + environment = AtlasInspectionPyBulletEnvironment(gui=gui) + try: + return run_episode( + environment, engine="PyBullet", max_duration_seconds=max_duration_seconds + ) + finally: + environment.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Atlas inspection episode in PyBullet.") + parser.add_argument("--max-duration", type=float, default=EPISODE_BUDGET_S) + parser.add_argument("--gui", action="store_true") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + result = run_inspection(args.max_duration, gui=args.gui) + rendered = json.dumps(result, indent=2) + print(rendered) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/pytest.ini b/bridge/boston_dynamics/atlas_bridge/pytest.ini new file mode 100644 index 000000000..934e31867 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +markers = + slow: exercises the real simulator end to end rather than a stand-in + + facilitator: reaches the live x402 facilitator over the network diff --git a/bridge/boston_dynamics/atlas_bridge/reach_envelope.py b/bridge/boston_dynamics/atlas_bridge/reach_envelope.py new file mode 100644 index 000000000..fad6dbdad --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/reach_envelope.py @@ -0,0 +1,140 @@ +"""Measure where free-standing Atlas can actually reach without losing balance. + +The inspection targets in :mod:`task` are not guesses: this sweep drives the +robot to a grid of candidate points and records, for each one, whether the arm +converged and whether the robot was still standing afterwards. The resulting +envelope is what the shelf geometry is chosen from, and it is regenerated as +evidence rather than asserted in prose. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np + +from .control_core import ShelfInspectionController +from .kinematics import jacobian +from .mujoco_env import AtlasInspectionEnvironment +from .task import FALL_THRESHOLD_M, STANCE_POSE, InspectionTarget + +#: Offsets from the settled home end-effector pose, in metres. +FORWARD_OFFSETS = (0.06, 0.12, 0.18, 0.21, 0.24, 0.30) +VERTICAL_OFFSETS = (0.20, 0.10, 0.00, -0.06, -0.12, -0.20) +#: A probe counts as reachable at this accuracy. +REACH_TOLERANCE_M = 0.03 +PROBE_BUDGET_S = 12.0 + + +def _probe(offset: np.ndarray) -> dict: + """Send the arm to one candidate point and report what happened.""" + environment = AtlasInspectionEnvironment() + observation = environment.reset(dict(STANCE_POSE)) + + # Settle into the stance first so the offset is measured from a known pose. + for _ in range(400): + observation = environment.step(dict(STANCE_POSE)) + home = environment.end_effector().copy() + + goal = home + offset + target = InspectionTarget("probe", *goal, tolerance_m=REACH_TOLERANCE_M, hold_steps=120) + controller = ShelfInspectionController(targets=(target,), budget_seconds=PROBE_BUDGET_S) + controller.reset(environment.joint_limits()) + + while observation["sim_time"] < PROBE_BUDGET_S: + plan = controller.step( + environment.end_effector(), + jacobian(environment.joint_angles(), base_rotation=environment.base_rotation()), + observation["sim_time"], + ) + observation = environment.step(plan.joint_targets) + if environment.fall_detected or controller.finished: + break + + outcomes = controller.diagnostics()["per_target"] + reached = bool(outcomes and outcomes[0]["reached"]) + error = outcomes[0]["final_error_m"] if outcomes else None + return { + "offset_forward_m": round(float(offset[0]), 3), + "offset_vertical_m": round(float(offset[2]), 3), + "goal": [round(float(v), 4) for v in goal], + "reached": reached, + "final_error_m": error, + "min_pelvis_height_m": round(float(environment.min_pelvis_height), 4), + "fall_detected": environment.fall_detected, + "shelf_contacts": environment.shelf_contacts, + "usable": bool(reached and not environment.fall_detected), + } + + +def sweep() -> dict: + probes = [ + _probe(np.array([forward, 0.0, vertical])) + for vertical in VERTICAL_OFFSETS + for forward in FORWARD_OFFSETS + ] + usable = [probe for probe in probes if probe["usable"]] + return { + "validation_type": "reach_envelope", + "robot_model": "Boston Dynamics Atlas v4", + "base": "free-standing (no weld, no external support)", + "tolerance_m": REACH_TOLERANCE_M, + "fall_threshold_m": FALL_THRESHOLD_M, + "probe_budget_s": PROBE_BUDGET_S, + "probes_total": len(probes), + "probes_usable": len(usable), + # Reported as the largest block in which *every* probe succeeded, not as + # a bounding box around scattered successes — a bounding box would imply + # coverage the sweep never demonstrated. + "conservative_core": _conservative_core(probes), + "probes": probes, + } + + +def _conservative_core(probes: list[dict]) -> dict | None: + """Largest forward/vertical block in which every probe is usable.""" + grid = {(p["offset_forward_m"], p["offset_vertical_m"]): p["usable"] for p in probes} + forwards = sorted({key[0] for key in grid}) + verticals = sorted({key[1] for key in grid}) + + best: dict | None = None + for first_f in range(len(forwards)): + for last_f in range(first_f, len(forwards)): + for first_v in range(len(verticals)): + for last_v in range(first_v, len(verticals)): + block = [ + grid[(forwards[f], verticals[v])] + for f in range(first_f, last_f + 1) + for v in range(first_v, last_v + 1) + ] + if not all(block): + continue + if best is None or len(block) > best["cells"]: + best = { + "cells": len(block), + "forward_range_m": [forwards[first_f], forwards[last_f]], + "vertical_range_m": [verticals[first_v], verticals[last_v]], + } + return best + + +def main() -> None: + parser = argparse.ArgumentParser(description="Measure the Atlas reach envelope.") + parser.add_argument( + "--json-output", type=Path, default=Path("docs/evidence/reach-envelope.json") + ) + args = parser.parse_args() + result = sweep() + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + core = result["conservative_core"] + print(f"{result['probes_usable']}/{result['probes_total']} probes usable") + if core: + print(f"conservative core: forward {core['forward_range_m']} m, " + f"vertical {core['vertical_range_m']} m ({core['cells']} probes)") + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/real_paid_run.py b/bridge/boston_dynamics/atlas_bridge/real_paid_run.py new file mode 100644 index 000000000..45d75c842 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/real_paid_run.py @@ -0,0 +1,520 @@ +"""One real payment for one real action, end to end. + +Every other demo in this package proves the *refusing* side: an unpaid action is +refused, a forged authorization is refused by the live facilitator, a replay is +refused. Those are the safety properties, and they are the easy half to prove +because they need no money. + +This module proves the accepting side, which is the half that needs a funded +wallet:: + + EIP-3009 authorization signed by a funded wallet + -> live x402 facilitator /verify -> isValid: true + -> Zenoh robot/tunnel/action + -> Atlas bridge -> MuJoCo -> 3/3 targets + -> Zenoh robot/tunnel/result, correlated by action_id + -> live x402 facilitator /settle -> real USDC moves on Base Sepolia + -> the transaction is read back from a public RPC and decoded + +Two properties are worth stating because they are what make the artifact mean +something rather than merely look impressive. + +**The authorization is bound to the action.** EIP-3009 authorizations carry a +32-byte nonce chosen by the signer. This module sets it to +``keccak256(action_id)``, so the nonce inside the signed authorization — and +inside the ``AuthorizationUsed`` event the token emits on chain — is derivable +from the action identifier alone. A reviewer can recompute it and check that +this settlement paid for *this* action and not some other one. Nothing else in +an x402 receipt ties a payment to the work it bought. + +**Settlement happens after execution, never before.** The facilitator is asked +to verify first, the robot runs second, and ``/settle`` is called only if the +episode actually reported every target reached. A failed episode leaves the +authorization signed but unspent, which is the behaviour the payment policy +claims and the one an operator is trusting. + +The payer's private key is read from ``SETTLEMENT_PRIVATE_KEY`` and is never +printed, logged, or written to any file — the artifact records the payer's +address, which is public, and the signature, which is what the facilitator +needs anyway. + +Usage:: + + SETTLEMENT_PRIVATE_KEY=0x... python -m bridge.boston_dynamics.atlas_bridge.real_paid_run \\ + --payer 0x... --json-output docs/evidence/real-paid-run.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import threading +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +from .bridge import ACTION_TOPIC, RESULT_TOPIC, ROBOT_ID, AtlasZenohBridge +from .facilitator import DEFAULT_FACILITATOR_URL, FACILITATOR_NETWORK +from .task import ( + BASE_SEPOLIA_CHAIN_ID, + PAYMENT_NETWORK, + SKILL_PRICE_RAW, + SKILL_PRICE_USDC, + USDC_BASE_SEPOLIA, + USDC_DECIMALS, +) + +SKILL_ID = "inspect_shelf" +#: The payee this profile has always settled to; see onchain-settlement.json. +DEFAULT_PAYEE = "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" +RESOURCE = "https://robopay.invalid/atlas/inspect_shelf" +RPC_URL = "https://sepolia.base.org" +EXPLORER = "https://sepolia.basescan.org" +USER_AGENT = "robopay-atlas-bridge/1.0" +EPISODE_SECONDS = 12.0 +RESULT_TIMEOUT_S = 240.0 +HTTP_TIMEOUT_S = 60.0 + +#: keccak256("Transfer(address,address,uint256)") +TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +#: keccak256("AuthorizationUsed(address,bytes32)") +AUTHORIZATION_USED_TOPIC = ( + "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5" +) + + +# -- plumbing --------------------------------------------------------------- +def _post(url: str, body: dict) -> tuple[int, dict]: + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"content-type": "application/json", "user-agent": USER_AGENT}, + ) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + try: + return error.code, json.loads(error.read()) + except Exception: # noqa: BLE001 - an unreadable body is still a refusal + return error.code, {} + + +def _rpc(method: str, params: list): + request = urllib.request.Request( + RPC_URL, + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(), + headers={"content-type": "application/json", "user-agent": USER_AGENT}, + ) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response: + return json.loads(response.read()).get("result") + + +# -- the payment ------------------------------------------------------------ +def sign_authorization(action_id: str, payee: str, valid_before: int) -> tuple[dict, str, str]: + """Sign an EIP-3009 authorization whose nonce is derived from ``action_id``. + + Returns ``(authorization, signature, payer_address)``. The private key is + read from the environment and never leaves this function. + """ + from eth_account import Account + from eth_utils import keccak + + key = os.environ.get("SETTLEMENT_PRIVATE_KEY", "").strip() + mnemonic = os.environ.get("SETTLEMENT_MNEMONIC", "").strip() + if key: + account = Account.from_key(key) + elif mnemonic: + # Accepting the recovery phrase directly saves the operator a + # conversion step, which is the step where a key usually ends up + # pasted somewhere it should not be. m/44'/60'/0'/0/ is what + # MetaMask and most wallets use; --account-index reaches the others. + Account.enable_unaudited_hdwallet_features() + index = int(os.environ.get("SETTLEMENT_ACCOUNT_INDEX", "0")) + account = Account.from_mnemonic( + mnemonic, account_path=f"m/44'/60'/0'/0/{index}" + ) + else: + raise SystemExit( + "Set SETTLEMENT_PRIVATE_KEY or SETTLEMENT_MNEMONIC in your own shell. " + "Either is read from the environment, used only to sign locally, and " + "never printed, logged, or written to any file." + ) + nonce = keccak(text=action_id) + value = int(SKILL_PRICE_RAW) + + typed = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + }, + "primaryType": "TransferWithAuthorization", + # Read off the token contract itself: name() == "USDC", version() == "2". + "domain": { + "name": "USDC", + "version": "2", + "chainId": BASE_SEPOLIA_CHAIN_ID, + "verifyingContract": USDC_BASE_SEPOLIA, + }, + "message": { + "from": account.address, + "to": payee, + "value": value, + "validAfter": 0, + "validBefore": valid_before, + "nonce": nonce, + }, + } + signature = account.sign_typed_data(full_message=typed).signature + authorization = { + "from": account.address, + "to": payee, + "value": str(value), + "validAfter": "0", + "validBefore": str(valid_before), + "nonce": "0x" + nonce.hex(), + } + return authorization, "0x" + signature.hex().lstrip("0x"), account.address + + +def payment_requirements(payee: str, resource: str) -> dict: + return { + "scheme": "exact", + "network": FACILITATOR_NETWORK, + "maxAmountRequired": SKILL_PRICE_RAW, + "resource": resource, + "description": "Boston Dynamics Atlas shelf inspection", + "mimeType": "application/json", + "payTo": payee, + "maxTimeoutSeconds": 60, + "asset": USDC_BASE_SEPOLIA, + "extra": {"name": "USDC", "version": "2"}, + } + + +def payment_payload(authorization: dict, signature: str) -> dict: + return { + "x402Version": 1, + "scheme": "exact", + "network": FACILITATOR_NETWORK, + "payload": {"signature": signature, "authorization": authorization}, + } + + +# -- the robot -------------------------------------------------------------- +class Correlator: + """Publishes one action on Zenoh and waits for its own result back.""" + + def __init__(self, session) -> None: + self._results: dict[str, dict] = {} + self._arrived = threading.Event() + self._publisher = session.declare_publisher(ACTION_TOPIC) + self._subscriber = session.declare_subscriber(RESULT_TOPIC, self._on_result) + + def _on_result(self, sample) -> None: + envelope = json.loads(bytes(sample.payload.to_bytes()).decode("utf-8")) + self._results[envelope.get("action_id", "")] = envelope + self._arrived.set() + + def publish(self, envelope: bytes) -> None: + self._publisher.put(envelope) + + def await_result(self, action_id: str, timeout: float) -> dict | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if action_id in self._results: + return self._results[action_id] + self._arrived.wait(0.25) + self._arrived.clear() + return None + + def close(self) -> None: + self._subscriber.undeclare() + self._publisher.undeclare() + + +def action_envelope(action_id: str, payment: dict, params: dict) -> bytes: + return json.dumps({ + "payload": { + "action": SKILL_ID, + "skill_id": SKILL_ID, + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": f"idem-{action_id}", + "params": params, + }, + "transaction_details": { + "payment_payload": payment, + "payment_requirements": payment.get("requirements"), + }, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }).encode("utf-8") + + +# -- on-chain confirmation -------------------------------------------------- +def confirm_on_chain(tx_hash: str, action_id: str) -> dict: + """Read the settlement back from a public RPC and decode what it did.""" + from eth_utils import keccak + + receipt = None + for _ in range(40): + receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) + if receipt: + break + time.sleep(3) + if not receipt: + return {"confirmed": False, "reason": "no receipt after ~2 minutes"} + + expected_nonce = "0x" + keccak(text=action_id).hex() + transfer: dict = {} + authorization_nonce = "" + for log in receipt.get("logs", []): + topics = log.get("topics", []) + if not topics: + continue + if topics[0].lower() == TRANSFER_TOPIC and len(topics) >= 3: + transfer = { + "token_contract": log["address"], + "from": "0x" + topics[1][-40:], + "to": "0x" + topics[2][-40:], + "raw_amount": int(log["data"], 16), + } + elif topics[0].lower() == AUTHORIZATION_USED_TOPIC and len(topics) >= 3: + authorization_nonce = topics[2] + + raw = transfer.get("raw_amount", 0) + return { + "confirmed": int(receipt.get("status", "0x0"), 16) == 1, + "block_number": int(receipt.get("blockNumber", "0x0"), 16), + "gas_used": int(receipt.get("gasUsed", "0x0"), 16), + "explorer": f"{EXPLORER}/tx/{tx_hash}", + "transfer": { + **transfer, + "amount_usdc": raw / 10**USDC_DECIMALS if raw else 0, + "asset": "USDC", + }, + "authorization_nonce": authorization_nonce, + "expected_nonce_from_action_id": expected_nonce, + # The point of the whole exercise: this settlement is provably the one + # that paid for this action, because the nonce is derived from its id. + "nonce_binds_settlement_to_action": ( + authorization_nonce.lower() == expected_nonce.lower() + ), + # The token really is the USDC the profile declares, not a lookalike. + "asset_is_declared_usdc": ( + transfer.get("token_contract", "").lower() == USDC_BASE_SEPOLIA.lower() + ), + "amount_matches_declared_price": str(raw) == SKILL_PRICE_RAW, + } + + +# -- the run ---------------------------------------------------------------- +def run(payee: str, expected_payer: str, facilitator_url: str) -> dict: + import zenoh + + from eth_utils import keccak + + action_id = f"act-paid-{uuid.uuid4().hex[:12]}" + params = {"maxDurationSec": EPISODE_SECONDS} + valid_before = int(time.time()) + 1800 + + print("=" * 72) + print(" Atlas — one real payment for one real action") + print("=" * 72) + print(f" action_id : {action_id}") + print(f" nonce : 0x{keccak(text=action_id).hex()} (= keccak256(action_id))") + print(f" price : {SKILL_PRICE_USDC} USDC ({SKILL_PRICE_RAW} raw) on {PAYMENT_NETWORK}") + + authorization, signature, payer = sign_authorization(action_id, payee, valid_before) + print(f" payer : {payer}") + if expected_payer and payer.lower() != expected_payer.lower(): + raise SystemExit( + f"the configured key signs for {payer}, but --payer says " + f"{expected_payer}. Refusing to continue.\n" + "If you supplied a recovery phrase, the wallet may use a different " + "account index — try SETTLEMENT_ACCOUNT_INDEX=1, 2, ... until the " + "address above matches." + ) + + resource = f"{RESOURCE}?action_id={action_id}" + requirements = payment_requirements(payee, resource) + payload = payment_payload(authorization, signature) + + steps: list[dict] = [] + + # 1. The live facilitator decides, before anything moves. + status, verdict = _post( + f"{facilitator_url}/verify", + {"x402Version": 1, "paymentPayload": payload, "paymentRequirements": requirements}, + ) + verified = verdict.get("isValid") is True + print(f"\n [verify] HTTP {status} isValid={verdict.get('isValid')}" + f" {verdict.get('invalidReason') or ''}") + steps.append({ + "step": "facilitator_verify", "http_status": status, + "is_valid": verified, "reason": verdict.get("invalidReason") or "", + "payer_recovered_by_facilitator": verdict.get("payer") or "", + "decided_by": "live x402 facilitator", + }) + if not verified: + print(" refused before execution — nothing published, nothing settled") + return _evidence(action_id, payer, payee, requirements, authorization, + steps, None, None, executed=False, settled=False) + + # 2. Only a verified payment reaches the robot. + bridge = AtlasZenohBridge() + session = zenoh.open(zenoh.Config()) + correlator = Correlator(session) + time.sleep(1.0) # let the peers discover each other before publishing + try: + print(f" [execute] publishing on {ACTION_TOPIC}") + correlator.publish(action_envelope(action_id, payload, params)) + result = correlator.await_result(action_id, RESULT_TIMEOUT_S) + finally: + correlator.close() + session.close() + bridge.close() + + if result is None: + print(" no result correlated within the timeout — not settling") + return _evidence(action_id, payer, payee, requirements, authorization, + steps, None, None, executed=False, settled=False) + + episode = result.get("result") or {} + completed = episode.get("targets_completed", 0) + total = episode.get("targets_total", 0) + succeeded = bool(episode.get("success")) and completed == total and total > 0 + print(f" [robot] {episode.get('status')} {completed}/{total} targets" + f" contacts={episode.get('shelf_contacts')}") + steps.append({ + "step": "robot_execution", "action_id_echoed": result.get("action_id"), + "correlated": result.get("action_id") == action_id, + "status": episode.get("status"), "targets_completed": completed, + "targets_total": total, "success": succeeded, + "transport": "Zenoh (peer mode)", + }) + + # 3. Settlement is the consequence of a successful episode, not of payment. + if not succeeded: + print(" episode did not succeed — the authorization stays unspent") + steps.append({"step": "settlement", "attempted": False, + "reason": "execution did not report every target reached"}) + return _evidence(action_id, payer, payee, requirements, authorization, + steps, result, None, executed=True, settled=False) + + status, settlement = _post( + f"{facilitator_url}/settle", + {"x402Version": 1, "paymentPayload": payload, "paymentRequirements": requirements}, + ) + tx_hash = settlement.get("transaction") or settlement.get("txHash") or "" + settled = settlement.get("success") is True and bool(tx_hash) + print(f" [settle] HTTP {status} success={settlement.get('success')} tx={tx_hash}") + steps.append({ + "step": "facilitator_settle", "http_status": status, + "success": settled, "tx_hash": tx_hash, + "error": settlement.get("errorReason") or settlement.get("error") or "", + "decided_by": "live x402 facilitator", + }) + + chain = confirm_on_chain(tx_hash, action_id) if settled else None + if chain: + print(f" [chain] block {chain['block_number']} " + f"{chain['transfer'].get('amount_usdc')} USDC " + f"bound to action_id: {chain['nonce_binds_settlement_to_action']}") + return _evidence(action_id, payer, payee, requirements, authorization, + steps, result, chain, executed=True, settled=settled) + + +def _evidence(action_id, payer, payee, requirements, authorization, + steps, result, chain, executed: bool, settled: bool) -> dict: + from eth_utils import keccak + + evidence = { + "evidence": "real_paid_action_end_to_end", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "skill_id": SKILL_ID, + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": f"idem-{action_id}", + "payment": { + "scheme": "exact", + "protocol": "x402 + EIP-3009 transferWithAuthorization", + "network": PAYMENT_NETWORK, + "asset": USDC_BASE_SEPOLIA, + "amount_raw": SKILL_PRICE_RAW, + "amount_usdc": SKILL_PRICE_USDC, + "payer": payer, + "payee": payee, + "authorization": authorization, + "nonce_derivation": "keccak256(action_id)", + "expected_nonce": "0x" + keccak(text=action_id).hex(), + "facilitator": DEFAULT_FACILITATOR_URL, + "requirements": requirements, + }, + "steps": steps, + "execution_result": result, + "on_chain": chain, + } + checks = [ + ("the live facilitator verified the authorization", + any(s.get("step") == "facilitator_verify" and s.get("is_valid") for s in steps)), + ("the robot executed only after that verification", executed), + ("every inspection target was reached", + any(s.get("step") == "robot_execution" and s.get("success") for s in steps)), + ("the result came back correlated by action_id", + any(s.get("step") == "robot_execution" and s.get("correlated") for s in steps)), + ("the facilitator settled a real transaction", settled), + ("the settlement is confirmed on Base Sepolia", + bool(chain and chain.get("confirmed"))), + ("the settled amount is the declared skill price", + bool(chain and chain.get("amount_matches_declared_price"))), + ("the asset is the USDC contract the profile declares", + bool(chain and chain.get("asset_is_declared_usdc"))), + ("the on-chain authorization nonce is keccak256(action_id)", + bool(chain and chain.get("nonce_binds_settlement_to_action"))), + ] + print("\n" + "=" * 72) + print(" INVARIANTS") + print("=" * 72) + for label, ok in checks: + print(f" [{'OK' if ok else '!!'}] {label}") + evidence["invariants"] = {label: ok for label, ok in checks} + evidence["all_invariants_hold"] = all(ok for _, ok in checks) + return evidence + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run one real paid Atlas action and settle it on Base Sepolia." + ) + parser.add_argument("--payer", default="", help="Address the key is expected to sign for.") + parser.add_argument("--payee", default=DEFAULT_PAYEE) + parser.add_argument("--facilitator", default=DEFAULT_FACILITATOR_URL) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + evidence = run(args.payee, args.payer, args.facilitator.rstrip("/")) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f"\n evidence written to {args.json_output}") + raise SystemExit(0 if evidence["all_invariants_hold"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/relay.py b/bridge/boston_dynamics/atlas_bridge/relay.py new file mode 100644 index 000000000..e4442b056 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/relay.py @@ -0,0 +1,186 @@ +"""Action relay with x402 payment gating. + +Orchestrates the full flow: + Request → 402 check → verify payment → execute skill → settle/skip + +This module connects x402 verification to the Atlas bridge execution, +providing the payment safety layer required by Fabric. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Callable + +from .x402 import X402Verifier, X402Error, X402VerificationResult +from .payment import SettlementLedger + + +LOGGER = logging.getLogger("robopay.atlas.relay") + + +@dataclass(frozen=True) +class ActionRequest: + action_id: str + robot_id: str + skill_id: str + params: dict + payment_header: str | dict | None + idempotency_key: str = "" + + +@dataclass(frozen=True) +class ActionResult: + action_id: str + status: str + skill_id: str + result: dict + settlement_status: str + http_status: int + + +class ActionRelay: + """Relay that gates skill execution behind x402 payment verification. + + Flow: + 1. Receive action request + 2. Check payment header presence + - Missing → HTTP 402, no execution, no settlement + 3. Verify payment receipt + - Invalid → HTTP 402/400, no execution, no settlement + 4. Execute skill + 5. On success → settlement approved + - On failure → no settlement + """ + + def __init__( + self, + verifier: X402Verifier, + ledger: SettlementLedger, + skill_executor: Callable[[ActionRequest], dict], + robot_id: str = "atlas-sim-01", + ): + self._verifier = verifier + self._ledger = ledger + self._skill_executor = skill_executor + self._robot_id = robot_id + + def handle_action(self, request: ActionRequest) -> ActionResult: + if request.robot_id != self._robot_id: + return ActionResult( + action_id=request.action_id, + status="error", + skill_id=request.skill_id, + result={"error_code": "ROBOT_MISMATCH", "message": "Robot ID mismatch."}, + settlement_status="skipped", + http_status=400, + ) + + verification = self._verifier.verify( + request.payment_header, + action_id=request.action_id, + ) + + if not verification.valid: + return self._handle_payment_failure(request, verification) + + if verification.receipt: + self._ledger.record_execution_start( + action_id=request.action_id, + skill_id=request.skill_id, + robot_id=request.robot_id, + tx_hash=verification.receipt.tx_hash, + amount=verification.receipt.amount, + asset=verification.receipt.asset, + network=verification.receipt.network, + ) + + try: + exec_result = self._skill_executor(request) + except Exception as error: + LOGGER.exception("Skill execution failed for %s", request.action_id) + self._ledger.skip_on_failure( + request.action_id, + reason=f"Execution exception: {error}", + ) + return ActionResult( + action_id=request.action_id, + status="error", + skill_id=request.skill_id, + result={ + "error_code": "EXECUTION_FAILED", + "message": str(error), + }, + settlement_status="skipped_failure", + http_status=500, + ) + + success = exec_result.get("success", False) + + if success: + # The relay runs in-process and holds no wallet, so it authorises + # settlement without performing one. Reporting "settled" here would + # claim a transfer that this path never makes; see + # real_paid_run.py for the path that actually moves USDC. + entry = self._ledger.settle_on_success(request.action_id) + return ActionResult( + action_id=request.action_id, + status="success", + skill_id=request.skill_id, + result=exec_result, + settlement_status=( + "settled" if entry and entry.settlement_tx_hash + else "settlement_eligible" + ), + http_status=200, + ) + else: + self._ledger.skip_on_failure( + request.action_id, + reason=exec_result.get("error_code", "Skill execution returned failure."), + ) + return ActionResult( + action_id=request.action_id, + status="error", + skill_id=request.skill_id, + result=exec_result, + settlement_status="skipped_failure", + http_status=200, + ) + + def _handle_payment_failure( + self, + request: ActionRequest, + verification: X402VerificationResult, + ) -> ActionResult: + error = verification.error + if error == X402Error.MISSING_PAYMENT: + self._ledger.record_unpaid( + request.action_id, request.skill_id, request.robot_id, + ) + http_status = 402 + elif error == X402Error.REPLAY_DETECTED: + self._ledger.record_rejected( + request.action_id, request.skill_id, request.robot_id, + reason=verification.message, + ) + http_status = 409 + else: + self._ledger.record_rejected( + request.action_id, request.skill_id, request.robot_id, + reason=verification.message, + ) + http_status = 400 + + return ActionResult( + action_id=request.action_id, + status="error", + skill_id=request.skill_id, + result={ + "error_code": error.value if error else "PAYMENT_ERROR", + "message": verification.message, + }, + settlement_status="skipped", + http_status=http_status, + ) diff --git a/bridge/boston_dynamics/atlas_bridge/requirements.txt b/bridge/boston_dynamics/atlas_bridge/requirements.txt new file mode 100644 index 000000000..d9e147be3 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/requirements.txt @@ -0,0 +1,42 @@ +# Core simulation and control. +# +# MuJoCo is pinned exactly, and only MuJoCo, because it is the one +# whose drift has actually been observed. On a commit whose only changes were to +# documentation, a clean CI environment resolved MuJoCo 3.12.0 and +# test_home_pose_matches_the_recorded_geometry measured the resting hand at +# 0.9816 m against the 0.9589 m recorded with the evidence build. The episode +# was almost unmoved — mean error differed by 0.00001 m, the pelvis floor not at +# all — but the shelf coordinates come from a reach envelope measured at that +# resting pose, so the geometry and the build that produced it travel together. +# The pin is exact rather than a <3.12 range: a 3.11.x patch is a different +# build from the one the numbers were recorded on, and the point is that a +# reviewer gets that build. +# +# numpy and pybullet are left floored. Bounding them too would be pinning on +# suspicion rather than on evidence, and it would cost anyone building this more +# than it buys. The integrity tests are the net: they are what caught MuJoCo, +# on the least likely commit, and they would catch these the same way. +mujoco==3.11.0 +numpy>=1.26 +pybullet>=3.2.6 + +# Registry/profile parsing and tests +pyyaml>=6.0 +pytest>=8.0 + +# Evidence rendering +pillow>=10.0 + +# Webots world generation (Webots itself must be installed separately) +urdf2webots>=2.0 + +# Tunnel transport. Imported lazily by bridge.py, so the simulator runs and the +# test-suite pass without a router; demo_tunnel.py needs it to exercise the real +# Zenoh path. +eclipse-zenoh>=1.0 + +# EIP-712 signing for the real paid path. real_paid_run.py and demo_fabric_e2e.py +# sign an EIP-3009 authorization locally; without these a clean checkout can run +# every simulator and payment-safety test but not the two paid demos. +eth-account>=0.13 +eth-utils>=5.0 diff --git a/bridge/boston_dynamics/atlas_bridge/runner.py b/bridge/boston_dynamics/atlas_bridge/runner.py new file mode 100644 index 000000000..5fd1ee0ad --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/runner.py @@ -0,0 +1,46 @@ +"""Run one Atlas shelf-inspection episode in MuJoCo.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Callable + +from .episode import run_episode +from .model import joint_efforts +from .mujoco_env import AtlasInspectionEnvironment +from .task import EPISODE_BUDGET_S + + +def run_inspection( + max_duration_seconds: float = EPISODE_BUDGET_S, + stop_requested: Callable[[], bool] | None = None, +) -> dict: + """Execute the inspection skill in MuJoCo and return its metrics.""" + return run_episode( + AtlasInspectionEnvironment(), + engine="MuJoCo", + max_duration_seconds=max_duration_seconds, + stop_requested=stop_requested, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Atlas shelf-inspection episode.") + parser.add_argument("--max-duration", type=float, default=EPISODE_BUDGET_S) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + joint_efforts() # fetches the pinned description on first run + result = run_inspection(args.max_duration) + rendered = json.dumps(result, indent=2) + print(rendered) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/settlement_evidence.py b/bridge/boston_dynamics/atlas_bridge/settlement_evidence.py new file mode 100644 index 000000000..9cd05619c --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/settlement_evidence.py @@ -0,0 +1,266 @@ +"""Re-verify the on-chain settlement directly against Base Sepolia. + +The bridge's x402 gate decides *whether* an action may settle; this module +records that a settlement of the gated skill really happened on chain. It reads +the transaction back from a public RPC endpoint and rebuilds the evidence from +what the chain returns, so the artefact cannot drift from reality — run it again +and it either reproduces or fails. + +Nothing here holds a key. Settlement is executed by the operator's own wallet; +this is the read-only receipt. +""" + +from __future__ import annotations + +import argparse +import json +import urllib.request +from pathlib import Path + +from .task import ( + BASE_SEPOLIA_CHAIN_ID as CHAIN_ID, + PAYMENT_NETWORK as NETWORK, + USDC_BASE_SEPOLIA as USDC_ADDRESS, + USDC_DECIMALS, +) + +RPC_URL = "https://sepolia.base.org" +EXPLORER = "https://sepolia.basescan.org" + +#: keccak256("Transfer(address,address,uint256)") +TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +#: keccak256("AuthorizationUsed(address,bytes32)") +AUTHORIZATION_USED_TOPIC = ( + "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5" +) + +#: The paid run whose settlement this verifies. Read from the artifact rather +#: than pinned here, so the check follows the evidence instead of drifting from +#: it — an earlier revision verified a 1.0 USDC transfer that had nothing to do +#: with any action while the profile's real settlement was 0.001 USDC. +PAID_RUN_ARTIFACT = Path("docs/evidence/real-paid-run.json") +#: The faucet request that funded the first test wallet. +FUNDING_TX = "0xb37252fda0bc30de9ce98bd1b306c131eda11a4b3fabd9ae11d487d8773fdbbb" + + +def settlement_under_test() -> tuple[str, str]: + """The transaction and action id to verify, taken from the paid-run artifact.""" + if not PAID_RUN_ARTIFACT.is_file(): + raise SystemExit(f"{PAID_RUN_ARTIFACT} is missing; nothing to verify against") + artifact = json.loads(PAID_RUN_ARTIFACT.read_text(encoding="utf-8")) + on_chain = artifact.get("on_chain") or {} + tx_hash = (on_chain.get("explorer") or "").rsplit("/", 1)[-1] + action_id = artifact.get("action_id", "") + if not tx_hash or not action_id: + raise SystemExit(f"{PAID_RUN_ARTIFACT} records no settlement to verify") + return tx_hash, action_id + + +def expectations() -> dict: + """What the profile says a settlement of this skill must look like. + + Taken from the profile and the paid run rather than restated here, so a + price or payee changed in one place cannot leave this check agreeing with + a stale copy of itself. + """ + from .task import SKILL_PRICE_RAW + + artifact = json.loads(PAID_RUN_ARTIFACT.read_text(encoding="utf-8")) + payment = artifact.get("payment") or {} + return { + "amount_raw": int(SKILL_PRICE_RAW), + "asset": USDC_ADDRESS.lower(), + "payer": str(payment.get("payer", "")).lower(), + "payee": str(payment.get("payee", "")).lower(), + "network": NETWORK, + } + + +def _rpc(method: str, params: list) -> dict | None: + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + request = urllib.request.Request( + RPC_URL, + data=payload.encode(), + headers={ + "content-type": "application/json", + # The public endpoint rejects requests without a user agent. + "user-agent": "robopay-atlas-bridge/1.0", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read()).get("result") + + +def _address(topic: str) -> str: + return "0x" + topic[-40:] + + +def verify_settlement(tx_hash: str = "", action_id: str = "") -> dict: + """Read the settlement transaction back from chain and decode its transfer. + + When an ``action_id`` is given the authorization nonce is checked too: the + profile derives it as ``keccak256(action_id)``, so a matching nonce in the + token's ``AuthorizationUsed`` event is what makes this transfer the one that + paid for that action rather than merely a transfer of the right size. + """ + if not tx_hash: + tx_hash, action_id = settlement_under_test() + receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) + if receipt is None: + raise RuntimeError(f"Transaction {tx_hash} not found on Base Sepolia") + + transfers = [ + log + for log in receipt["logs"] + if log["topics"] and log["topics"][0].lower() == TRANSFER_TOPIC + and log["address"].lower() == USDC_ADDRESS.lower() + ] + if not transfers: + raise RuntimeError(f"{tx_hash} carries no USDC Transfer event") + transfer = transfers[0] + raw = int(transfer["data"], 16) + + nonce = "" + for log in receipt["logs"]: + topics = log.get("topics") or [] + if topics and topics[0].lower() == AUTHORIZATION_USED_TOPIC and len(topics) >= 3: + nonce = topics[2] + + expected_nonce = "" + if action_id: + from eth_utils import keccak + + expected_nonce = "0x" + keccak(text=action_id).hex() + + expected = expectations() + mismatches = [] + if raw != expected["amount_raw"]: + mismatches.append( + f"amount {raw} is not the declared price {expected['amount_raw']}") + if transfer["address"].lower() != expected["asset"]: + mismatches.append(f"asset {transfer['address']} is not {USDC_ADDRESS}") + if expected["payer"] and _address(transfer["topics"][1]).lower() != expected["payer"]: + mismatches.append( + f"payer {_address(transfer['topics'][1])} is not {expected['payer']}") + if expected["payee"] and _address(transfer["topics"][2]).lower() != expected["payee"]: + mismatches.append( + f"payee {_address(transfer['topics'][2])} is not {expected['payee']}") + + return { + "hash": tx_hash, + "action_id": action_id, + "expected": expected, + "mismatches": mismatches, + "matches_profile": not mismatches, + "succeeded": receipt["status"] == "0x1", + "authorization_nonce": nonce, + "expected_nonce_from_action_id": expected_nonce, + "nonce_binds_settlement_to_action": bool(nonce) + and nonce.lower() == expected_nonce.lower(), + "block_number": int(receipt["blockNumber"], 16), + "gas_used": int(receipt["gasUsed"], 16), + "contract": receipt["to"], + "explorer": f"{EXPLORER}/tx/{tx_hash}", + "transfer": { + "event": "Transfer(address,address,uint256)", + "token": "USDC", + "token_contract": USDC_ADDRESS, + "from": _address(transfer["topics"][1]), + "to": _address(transfer["topics"][2]), + "raw_amount": raw, + "decimals": USDC_DECIMALS, + "amount": raw / 10**USDC_DECIMALS, + }, + } + + +def collect() -> dict: + """Build the settlement evidence entirely from what the chain returns.""" + settlement = verify_settlement() + funding_receipt = _rpc("eth_getTransactionReceipt", [FUNDING_TX]) + + return { + "evidence": "on_chain_settlement", + "network": {"name": "Base Sepolia", "chain_id": CHAIN_ID, "caip2": NETWORK}, + "asset": { + "symbol": "USDC", + "contract": USDC_ADDRESS, + "decimals": USDC_DECIMALS, + "explorer": f"{EXPLORER}/token/{USDC_ADDRESS}", + }, + "settlement_transaction": settlement, + "funding_transaction": { + "hash": FUNDING_TX, + "succeeded": bool(funding_receipt) and funding_receipt["status"] == "0x1", + "block_number": int(funding_receipt["blockNumber"], 16) if funding_receipt else None, + "explorer": f"{EXPLORER}/tx/{FUNDING_TX}", + "description": ( + "Coinbase Developer Platform faucet request that funded the payer " + "wallet with testnet USDC before the settlement above." + ), + }, + "wallets": { + "payer": { + "address": settlement["transfer"]["from"], + "explorer": f"{EXPLORER}/address/{settlement['transfer']['from']}", + }, + "payee": { + "address": settlement["transfer"]["to"], + "explorer": f"{EXPLORER}/address/{settlement['transfer']['to']}", + }, + }, + "notes": [ + "Testnet only. Base Sepolia USDC has no monetary value.", + "This artefact deliberately records no balances: balances change after " + "the fact, while the transaction and its Transfer event do not.", + "The payer wallet is a disposable test wallet and is treated as " + "compromised; no key material is stored in this repository.", + ], + "reproduce": ( + "python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence" + ), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Verify the on-chain settlement.") + parser.add_argument( + "--json-output", + type=Path, + default=Path("docs/evidence/onchain-settlement.json"), + ) + args = parser.parse_args() + + evidence = collect() + settlement = evidence["settlement_transaction"] + transfer = settlement["transfer"] + + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + + print(f"settlement tx : {settlement['hash']}") + print(f" for action : {settlement['action_id']}") + print(f" succeeded : {settlement['succeeded']}") + print(f" bound to it : {settlement['nonce_binds_settlement_to_action']}" + f" (nonce = keccak256(action_id))") + print(f" matches : {settlement['matches_profile']}" + f" (amount, asset, payer, payee against the profile)") + for mismatch in settlement["mismatches"]: + print(f" !! {mismatch}") + print(f" block : {settlement['block_number']}") + print(f" transfer : {transfer['amount']} {transfer['token']}") + print(f" from : {transfer['from']}") + print(f" to : {transfer['to']}") + print(f" explorer : {settlement['explorer']}") + # A settlement that is not bound to the action it paid for proves the + # asset moved, not that this action was the reason. + raise SystemExit( + 0 if settlement["succeeded"] + and settlement["nonce_binds_settlement_to_action"] + and settlement["matches_profile"] else 1 + ) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/sim2sim.py b/bridge/boston_dynamics/atlas_bridge/sim2sim.py new file mode 100644 index 000000000..bcb69bf83 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/sim2sim.py @@ -0,0 +1,121 @@ +"""Sim-to-sim validation across MuJoCo, PyBullet and Webots. + +Every engine runs the *same* pinned Atlas v4 URDF, the *same* shelf geometry from +:mod:`task` and the *same* :class:`~.control_core.ShelfInspectionController`. +The comparison therefore isolates the physics engine, which is the only thing +that differs between the runs. + +Webots is optional: it is only included when a Webots installation is present. +Its absence is reported explicitly rather than being silently swallowed — a +missing engine never turns a failed comparison into a passing one. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .task import INSPECTION_TARGETS + +#: Every engine must complete every target for the comparison to pass. +REQUIRED_TARGETS = len(INSPECTION_TARGETS) +#: Largest tolerated spread in mean end-effector error between engines. +MAX_MEAN_ERROR_SPREAD_M = 0.05 +#: Largest tolerated spread in episode duration between engines. +MAX_DURATION_SPREAD_S = 5.0 + + +def _summary(result: dict) -> dict: + return { + "engine": result["simulator_engine"], + "status": result["status"], + "targets_completed": result["targets_completed"], + "targets_total": result["targets_total"], + "mean_position_error_m": result["mean_position_error_m"], + "max_position_error_m": result["max_position_error_m"], + "min_pelvis_height_m": result["min_pelvis_height_m"], + "fall_detected": result["fall_detected"], + "shelf_contacts": result["shelf_contacts"], + "sim_duration_seconds": result["sim_duration_seconds"], + "per_target": result["policy_state"]["per_target"], + } + + +def _spread(values: list[float]) -> float: + return round(max(values) - min(values), 5) + + +def run_sim2sim(max_duration: float | None = None, include_webots: bool = True) -> dict: + """Run the task on every available engine and compare the outcomes.""" + from .pybullet_runner import run_inspection as run_pybullet + from .runner import run_inspection as run_mujoco + + kwargs = {} if max_duration is None else {"max_duration_seconds": max_duration} + runs = [run_mujoco(**kwargs), run_pybullet(**kwargs)] + + webots_status = "not_requested" + if include_webots: + from .webots_env import run_webots_episode, webots_available + + if webots_available(): + runs.append(run_webots_episode(**kwargs)) + webots_status = "ran" + else: + webots_status = "unavailable_no_webots_installation" + + summaries = [_summary(result) for result in runs] + all_complete = all(s["targets_completed"] == REQUIRED_TARGETS for s in summaries) + none_fell = all(not s["fall_detected"] for s in summaries) + no_contacts = all(s["shelf_contacts"] == 0 for s in summaries) + error_spread = _spread([s["mean_position_error_m"] or 0.0 for s in summaries]) + duration_spread = _spread([s["sim_duration_seconds"] for s in summaries]) + + consistent = ( + all_complete + and none_fell + and no_contacts + and error_spread <= MAX_MEAN_ERROR_SPREAD_M + and duration_spread <= MAX_DURATION_SPREAD_S + ) + + return { + "validation_type": "sim2sim", + "robot_model": runs[0]["robot_model"], + "model_source": runs[0]["model_source"], + "policy_id": runs[0]["policy_id"], + "engines": [s["engine"] for s in summaries], + "webots": webots_status, + "runs": summaries, + "consistency": { + "all_engines_completed_all_targets": all_complete, + "no_engine_reported_a_fall": none_fell, + "no_engine_reported_shelf_contact": no_contacts, + "mean_position_error_spread_m": error_spread, + "mean_position_error_spread_limit_m": MAX_MEAN_ERROR_SPREAD_M, + "duration_spread_s": duration_spread, + "duration_spread_limit_s": MAX_DURATION_SPREAD_S, + }, + "verdict": "PASS" if consistent else "FAIL", + "full_results": runs, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Atlas sim-to-sim validation.") + parser.add_argument("--max-duration", type=float) + parser.add_argument("--no-webots", action="store_true") + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + result = run_sim2sim(args.max_duration, include_webots=not args.no_webots) + rendered = json.dumps(result, indent=2) + print(rendered) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result["verdict"] == "PASS" else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/task.py b/bridge/boston_dynamics/atlas_bridge/task.py new file mode 100644 index 000000000..dbeece269 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/task.py @@ -0,0 +1,107 @@ +"""Simulator-independent geometry for the Atlas shelf-inspection task. + +Every simulator (MuJoCo, PyBullet, Webots) builds its scene from the constants +in this module, so the three runs are the same task by construction and their +metrics are directly comparable. + +Frame: world coordinates, +x in front of the robot, +y to its left, +z up. +The robot stands at the origin and inspects a shelf with its right hand. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +#: Standing pose held by the legs and left arm for the whole episode. +STANCE_POSE: dict[str, float] = { + "l_leg_hpy": -0.30, "l_leg_kny": 0.62, "l_leg_aky": -0.32, + "r_leg_hpy": -0.30, "r_leg_kny": 0.62, "r_leg_aky": -0.32, + "l_arm_shx": -1.35, "r_arm_shx": 1.35, + "l_arm_elx": 1.20, "r_arm_elx": -1.20, + "l_arm_ely": 1.00, "r_arm_ely": 1.00, +} + +#: Joints the inspection controller is allowed to move (right arm only). +INSPECTION_CHAIN: tuple[str, ...] = ("r_arm_shz", "r_arm_shx", "r_arm_ely", "r_arm_elx") + +#: End effector whose pose is measured against each inspection target. +END_EFFECTOR_BODY = "r_hand" + +#: Pose the right hand settles into before the first target, measured from the +#: pinned model. ``tests/test_model_integrity.py`` fails if the model drifts. +HOME_END_EFFECTOR = (0.3907, -0.5592, 0.9589) +HOME_PELVIS_HEIGHT_M = 0.911 + + +@dataclass(frozen=True) +class InspectionTarget: + """One shelf position the end effector must reach and hold.""" + + name: str + x: float + y: float + z: float + #: Distance under which the target counts as reached, in metres. + tolerance_m: float = 0.03 + #: Consecutive control steps the hand must stay inside the tolerance. + hold_steps: int = 250 + + @property + def position(self) -> tuple[float, float, float]: + return (self.x, self.y, self.z) + + +#: Three shelf points, all inside the conservative reach core measured by +#: ``reach_envelope.py`` and recorded in ``docs/evidence/reach-envelope.json``: +#: 0.06-0.18 m forward and -0.12..+0.20 m vertical of :data:`HOME_END_EFFECTOR`, +#: a block in which every one of the 15 probes reached its target with the robot +#: still standing. These three sit at 0.13-0.15 m forward and -0.06..+0.06 m +#: vertical, in the open bay between the shelf plates, so a straight-line +#: approach from the home pose never crosses a plate. +INSPECTION_TARGETS: tuple[InspectionTarget, ...] = ( + InspectionTarget("shelf-top", 0.52, -0.56, 1.02), + InspectionTarget("shelf-middle", 0.54, -0.60, 0.94), + InspectionTarget("shelf-lower", 0.52, -0.52, 0.90), +) + +#: Static shelf structure, drawn as collision geometry in every simulator. +#: ``half`` is the box half-extent in metres. The plates start at x = 0.62, in +#: front of which the inspection bay stays clear. +SHELF_PARTS: tuple[dict, ...] = ( + {"name": "shelf_back", "pos": (0.87, -0.56, 0.90), "half": (0.02, 0.30, 0.30)}, + {"name": "shelf_upper", "pos": (0.76, -0.56, 1.10), "half": (0.10, 0.30, 0.01)}, + {"name": "shelf_lower", "pos": (0.76, -0.56, 0.66), "half": (0.10, 0.30, 0.01)}, +) + +#: Pelvis height below which the episode is declared a fall. Atlas stands at +#: 0.911 m; 0.70 m is unambiguously "no longer standing" rather than the 0.05 m +#: floor-contact threshold used by the previous revision. +FALL_THRESHOLD_M = 0.70 + +#: Wall-clock budget for the whole inspection sequence, in simulated seconds. +EPISODE_BUDGET_S = 30.0 + +#: Joint-servo gains, shared by every engine so the servo law is one decision +#: rather than three. Webots defaults to P=10, which cannot hold a 182 kg +#: humanoid upright, so each backend sets these explicitly. +SERVO_KP = 3000.0 +SERVO_KD = 150.0 + +#: --- payment contract ------------------------------------------------------ +#: The skill price, kept here so the profile, the bridge, the demo and the +#: settlement layer cannot drift apart. ``registry/.../payment-policy.yaml`` +#: declares the same number and ``tests/test_payment_contract.py`` pins them +#: together. +SKILL_PRICE_USDC = "0.001" +USDC_DECIMALS = 6 +#: The same price in the raw integer units an x402 receipt carries. +SKILL_PRICE_RAW = "1000" +#: Circle's USDC on Base Sepolia, and the network the profile settles on. +USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +BASE_SEPOLIA_CHAIN_ID = 84532 +PAYMENT_NETWORK = "eip155:84532" + +#: Webots drives joints through its own implicit position servo, whose gain is a +#: velocity-level term and so is not comparable to :data:`SERVO_KP`. Measured: +#: P=10 (the Webots default) lets Atlas topple, P>=80 holds the stance. +WEBOTS_SERVO_P = 120.0 diff --git a/bridge/boston_dynamics/atlas_bridge/tests/__init__.py b/bridge/boston_dynamics/atlas_bridge/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_bridge_contract.py b/bridge/boston_dynamics/atlas_bridge/tests/test_bridge_contract.py new file mode 100644 index 000000000..bd6639210 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_bridge_contract.py @@ -0,0 +1,349 @@ +"""The tunnel bridge must actually execute the registered skill. + +These tests exist because of a real defect: the bridge was left wired to a +previous skill (``navigate_obstacles`` / ``run_obstacle_nav``) after the profile +moved to ``inspect_shelf``. Nothing caught it — the simulator tests never +imported the bridge, so the module did not even import successfully. + +Everything here drives the same code path Zenoh drives, using the action +envelopes that ship in the registry profile, and checks the correlation fields +the tunnel needs to match an asynchronous result to a paid request. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from bridge.boston_dynamics.atlas_bridge import bridge as bridge_module +from bridge.boston_dynamics.atlas_bridge.bridge import ( + ALLOWED_ACTIONS, + INSPECTION_PARAMS, + PROFILE_ID, + AtlasActionHandler, + inspection_params, +) +from bridge.boston_dynamics.atlas_bridge.idempotency import IdempotencyStore + +PROFILE_DIR = ( + Path(bridge_module.__file__).resolve().parents[3] + / "registry" / "vendors" / "boston-dynamics" / "atlas" + / "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" +) + + +def envelope(name: str) -> bytes: + """The action envelope shipped in the profile, byte for byte.""" + return (PROFILE_DIR / "examples" / f"action-envelope.{name}.json").read_bytes() + + +def custom_envelope(**payload) -> bytes: + body = { + "payload": { + "action": "inspect_shelf", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "action_id": "act-test-0001", + "idempotency_key": "idem-test-0001", + "params": {}, + **payload, + }, + "timestamp": "2026-08-19T00:00:00Z", + } + return json.dumps(body).encode("utf-8") + + +class Recorder: + """Captures whatever the handler would have published on the tunnel.""" + + def __init__(self) -> None: + self.messages: list[dict] = [] + + def __call__(self, payload: bytes) -> None: + self.messages.append(json.loads(payload.decode("utf-8"))) + + @property + def last(self) -> dict: + assert self.messages, "the bridge published nothing" + return self.messages[-1] + + +def fake_success(max_duration_seconds: float, stop_requested=None) -> dict: + return { + "success": True, + "status": "success", + "targets_completed": 3, + "targets_total": 3, + "sim_duration_seconds": max_duration_seconds, + } + + +def handler(execute=fake_success, **kwargs) -> tuple[AtlasActionHandler, Recorder]: + """A handler with an isolated in-memory idempotency store.""" + recorder = Recorder() + kwargs.setdefault("idempotency", IdempotencyStore(path=None)) + return ( + AtlasActionHandler(recorder, execute=execute, synchronous=True, **kwargs), + recorder, + ) + + +# -- the bridge and the registry must describe the same robot --------------- +def test_bridge_profile_id_matches_the_registry(): + profile = yaml.safe_load((PROFILE_DIR / "robot.profile.yaml").read_text(encoding="utf-8")) + assert PROFILE_ID == profile["profileId"] + + +def test_bridge_executes_exactly_the_registered_skills(): + skills = yaml.safe_load((PROFILE_DIR / "skills.yaml").read_text(encoding="utf-8")) + assert ALLOWED_ACTIONS == {entry["skillId"] for entry in skills["skills"]} + + +def test_bridge_accepts_exactly_the_registered_parameters(): + skills = yaml.safe_load((PROFILE_DIR / "skills.yaml").read_text(encoding="utf-8")) + declared = next(s for s in skills["skills"] if s["skillId"] == "inspect_shelf") + assert INSPECTION_PARAMS == set(declared["params"]) + + +# -- the shipped envelopes must actually work ------------------------------- +def test_shipped_inspect_envelope_reaches_the_skill(): + executed = {} + + def execute(max_duration_seconds: float, stop_requested=None) -> dict: + executed["max_duration_seconds"] = max_duration_seconds + return fake_success(max_duration_seconds, stop_requested) + + action, recorder = handler(execute=execute) + assert action.handle(envelope("inspect_shelf")) == "executed" + assert executed["max_duration_seconds"] == 30 + assert recorder.last["status"] == "success" + + +def test_shipped_stop_envelope_is_accepted(): + action, recorder = handler() + assert action.handle(envelope("stop")) == "stop" + assert recorder.last["status"] == "success" + assert recorder.last["result"]["safe_stop_applied"] is True + + +# -- correlation fields the tunnel needs ------------------------------------ +def test_result_echoes_every_correlation_field(): + action, recorder = handler() + action.handle(custom_envelope(action_id="act-corr-77", idempotency_key="idem-corr-77")) + result = recorder.last + assert result["action_id"] == "act-corr-77" + assert result["idempotency_key"] == "idem-corr-77" + assert result["robot_id"] == "atlas-sim-01" + assert result["skill_id"] == "inspect_shelf" + assert result["profile_id"] == PROFILE_ID + # Always the sha256: form the execution mapping declares, including for + # an empty parameter set. + assert result["params_hash"].startswith("sha256:") + assert len(result["params_hash"]) == len("sha256:") + 64 + + +def test_params_hash_always_matches_the_published_format(): + """``execution-mapping.yaml`` declares sha256:; nothing may differ.""" + action, recorder = handler() + for params in ({}, {"maxDurationSec": 12}): + action.handle(custom_envelope(action_id=f"act-{len(params)}", params=params)) + digest = recorder.last["params_hash"] + assert digest.startswith("sha256:") + assert len(digest) == len("sha256:") + 64 + + +def test_missing_skill_id_is_not_inferred_from_the_action(): + """A caller that omits skill_id has not said what it is paying for.""" + body = json.loads(custom_envelope().decode()) + del body["payload"]["skill_id"] + action, recorder = handler() + assert action.handle(json.dumps(body).encode()) == "failure" + assert recorder.last["result"]["error_code"] == "MISSING_IDENTITY" + + +def test_params_hash_is_derived_from_the_parameters(): + action, recorder = handler() + action.handle(custom_envelope(params={"maxDurationSec": 12})) + first = recorder.last["params_hash"] + action.handle(custom_envelope(params={"maxDurationSec": 12})) + assert recorder.last["params_hash"] == first + action.handle(custom_envelope(params={"maxDurationSec": 13})) + assert recorder.last["params_hash"] != first + + +# -- rejection paths -------------------------------------------------------- +def test_unregistered_action_is_refused_without_executing(): + calls = [] + action, recorder = handler(execute=lambda **kw: calls.append(kw) or fake_success(1)) + action.handle(custom_envelope(action="navigate_obstacles", skill_id="navigate_obstacles")) + assert recorder.last["status"] == "failure" + assert recorder.last["result"]["error_code"] == "UNREGISTERED_ACTION" + assert calls == [] + + +def test_action_and_skill_must_agree(): + action, recorder = handler() + action.handle(custom_envelope(action="inspect_shelf", skill_id="stop")) + assert recorder.last["result"]["error_code"] == "ACTION_SKILL_MISMATCH" + + +def test_action_for_another_robot_is_ignored(): + action, recorder = handler() + assert action.handle(custom_envelope(robot_id="some-other-robot")) == "ignored_foreign_robot" + assert recorder.messages == [] + + +def test_malformed_payload_is_rejected_before_simulation(): + action, recorder = handler() + assert action.handle(b"not json at all") == "rejected_malformed" + assert recorder.messages == [] + + +@pytest.mark.parametrize( + "params", + [ + {"maxDurationSec": 1}, # below the declared minimum + {"maxDurationSec": 600}, # above the declared maximum + {"maxDurationSec": "thirty"}, # wrong type + {"maxDurationSec": True}, # bool is not a number here + {"side": "left"}, # parameter the skill does not declare + ], +) +def test_invalid_parameters_are_refused(params): + action, recorder = handler() + action.handle(custom_envelope(params=params)) + assert recorder.last["status"] == "failure" + assert recorder.last["result"]["error_code"] in {"INVALID_PARAMS", "INVALID_DURATION"} + + +def test_stop_rejects_parameters(): + action, recorder = handler() + action.handle(custom_envelope(action="stop", skill_id="stop", params={"maxDurationSec": 10})) + assert recorder.last["result"]["error_code"] == "INVALID_PARAMS" + + +def test_simulator_failure_is_reported_not_swallowed(): + def explode(max_duration_seconds: float, stop_requested=None) -> dict: + raise RuntimeError("simulator exploded") + + action, recorder = handler(execute=explode) + action.handle(custom_envelope()) + assert recorder.last["status"] == "failure" + assert recorder.last["result"]["error_code"] == "SIMULATOR_EXECUTION_ERROR" + + +def test_failed_execution_is_reported_as_failure(): + def failing(max_duration_seconds: float, stop_requested=None) -> dict: + return {"success": False, "status": "failure", "targets_completed": 1} + + action, recorder = handler(execute=failing) + action.handle(custom_envelope()) + assert recorder.last["status"] == "failure" + + +def test_duration_default_matches_the_declared_default(): + assert inspection_params({}) == 30.0 + assert inspection_params({"maxDurationSec": 12}) == 12.0 + + +# -- the real thing, end to end -------------------------------------------- +@pytest.mark.slow +def test_shipped_envelope_drives_the_real_simulator(): + """No stubs: the profile's own envelope runs the MuJoCo inspection.""" + action, recorder = handler(execute=None) + assert action.handle(envelope("inspect_shelf")) == "executed" + + result = recorder.last + assert result["status"] == "success" + assert result["skill_id"] == "inspect_shelf" + assert result["profile_id"] == PROFILE_ID + assert result["result"]["targets_completed"] == result["result"]["targets_total"] == 3 + assert result["result"]["shelf_contacts"] == 0 + assert result["result"]["fall_detected"] is False + assert result["result"]["robot_model"] == "Boston Dynamics Atlas v4" + + +# -- schema compatibility with whatever the tunnel forwards ------------------ +def camel_envelope(**payload) -> bytes: + """The same action expressed in camelCase, as a JS/Go caller would send it.""" + body = { + "payload": { + "action": "inspect_shelf", + "skillId": "inspect_shelf", + "robotId": "atlas-sim-01", + "actionId": "act-camel-0001", + "idempotencyKey": "idem-camel-0001", + "params": {"maxDurationSec": 12}, + **payload, + }, + "transaction_details": {"paymentPayload": {"amount": "1000"}}, + "timestamp": "2026-08-19T00:00:00Z", + } + return json.dumps(body).encode("utf-8") + + +def test_camel_case_envelope_is_understood(): + """The tunnel forwards the caller's body verbatim, so casing is theirs. + + A bridge that only understood snake_case would pass every local test and + then silently ignore a real Fabric request. + """ + action, recorder = handler() + assert action.handle(camel_envelope()) == "executed" + result = recorder.last + assert result["action_id"] == "act-camel-0001" + assert result["idempotency_key"] == "idem-camel-0001" + assert result["robot_id"] == "atlas-sim-01" + assert result["skill_id"] == "inspect_shelf" + assert result["status"] == "success" + + +def test_both_spellings_produce_the_same_params_hash(): + action, recorder = handler() + action.handle(camel_envelope()) + camel_hash = recorder.last["params_hash"] + action.handle(custom_envelope(params={"maxDurationSec": 12})) + assert recorder.last["params_hash"] == camel_hash + + +def test_params_hash_is_recomputed_not_trusted(): + """A caller cannot declare one params hash and send different parameters.""" + body = json.loads(camel_envelope().decode()) + body["payload"]["paramsHash"] = "sha256:deadbeef" + action, recorder = handler() + action.handle(json.dumps(body).encode()) + assert recorder.last["params_hash"] != "sha256:deadbeef" + + +def test_payment_details_survive_the_parse(): + from bridge.boston_dynamics.atlas_bridge.bridge import load_event_parser + + event = load_event_parser()(camel_envelope()) + assert event.payment_payload == {"amount": "1000"} + + +# -- identity the tunnel correlates on is mandatory ------------------------- +@pytest.mark.parametrize("missing", ["action_id", "robot_id", "skill_id", "idempotency_key"]) +def test_missing_identity_is_refused(missing): + """A result nobody can correlate is worse than no result.""" + body = json.loads(custom_envelope().decode()) + if missing == "robot_id": + # An absent robot_id is not addressed to this robot at all. + body["payload"]["robot_id"] = "" + action, recorder = handler() + assert action.handle(json.dumps(body).encode()) == "ignored_foreign_robot" + return + body["payload"][missing] = "" + action, recorder = handler() + assert action.handle(json.dumps(body).encode()) == "failure" + assert recorder.last["result"]["error_code"] == "MISSING_IDENTITY" + assert missing in recorder.last["result"]["message"] + + +def test_complete_identity_is_accepted(): + action, recorder = handler() + assert action.handle(custom_envelope()) == "executed" + assert recorder.last["status"] == "success" diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_facilitator.py b/bridge/boston_dynamics/atlas_bridge/tests/test_facilitator.py new file mode 100644 index 000000000..4cb390148 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_facilitator.py @@ -0,0 +1,198 @@ +"""A forged payment must be rejected by the real facilitator, not by shape alone. + +The protocol checks in :mod:`x402` cannot tell a real authorization from a +well-formed forgery — the amount, the asset, the network and the hash shape can +all be perfect on a payload nobody ever signed. Only the facilitator recovers +the signer, so these tests drive the actual x402 facilitator and assert that a +forged authorization is refused before anything is executed or settled. + +The network-touching cases are marked ``facilitator`` so they can be deselected +offline; the fail-closed behaviour is checked without a network either way. +""" + +from __future__ import annotations + +import pytest + +from bridge.boston_dynamics.atlas_bridge.facilitator import ( + DEFAULT_FACILITATOR_URL, + FacilitatorClient, + FacilitatorVerdict, + payment_requirements, +) +from bridge.boston_dynamics.atlas_bridge.relay import ActionRelay, ActionRequest +from bridge.boston_dynamics.atlas_bridge.payment import SettlementLedger +from bridge.boston_dynamics.atlas_bridge.task import ( + PAYMENT_NETWORK, + SKILL_PRICE_RAW, + USDC_BASE_SEPOLIA, +) +from bridge.boston_dynamics.atlas_bridge.x402 import ( + PaymentPolicy, + X402Error, + X402Verifier, +) + +PAYEE = "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" +PAYER = "0x520C3Ff276456A217c0dFadABeEb2d7081d6cCd4" +RESOURCE = "https://robopay.invalid/atlas/inspect_shelf" + + +def forged_authorization() -> dict: + """A payload that is structurally perfect and cryptographically worthless.""" + return { + "x402Version": 1, + "scheme": "exact", + "network": "base-sepolia", + "payload": { + "signature": "0x" + "11" * 65, + "authorization": { + "from": PAYER, + "to": PAYEE, + "value": SKILL_PRICE_RAW, + "validAfter": "0", + "validBefore": "9999999999", + "nonce": "0x" + "22" * 32, + }, + }, + } + + +def receipt_with(authorization: dict) -> dict: + """A receipt that passes every protocol check the bridge applies.""" + return { + "amount": SKILL_PRICE_RAW, + "asset": "USDC", + "network": PAYMENT_NETWORK, + "txHash": "0x" + "5b" * 32, + "payer": PAYER, + "payee": PAYEE, + "paymentPayload": authorization, + } + + +def requirements() -> dict: + return payment_requirements(pay_to=PAYEE, resource=RESOURCE) + + +class StubFacilitator: + def __init__(self, verdict: FacilitatorVerdict) -> None: + self.verdict = verdict + self.calls = 0 + + def verify(self, payload, requirements): # noqa: ARG002 - signature parity + self.calls += 1 + return self.verdict + + +# -- what the verifier promises about itself ------------------------------- +def test_verifier_admits_when_it_is_only_a_protocol_check(): + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + assert X402Verifier(policy).verifies_authorization is False + assert X402Verifier(policy, facilitator=FacilitatorClient()).verifies_authorization is True + + +def test_protocol_checks_alone_accept_a_forgery(): + """The gap this module exists to close, stated as a test.""" + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + result = X402Verifier(policy).verify(receipt_with(forged_authorization())) + assert result.valid is True, "shape checks cannot detect a forged signature" + + +# -- fail-closed behaviour, no network needed ------------------------------ +def test_unreachable_facilitator_refuses_the_payment(): + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + unreachable = FacilitatorClient(url="http://127.0.0.1:1", timeout=1.0) + result = X402Verifier(policy, facilitator=unreachable, payment_requirements=requirements()).verify( + receipt_with(forged_authorization()) + ) + assert result.valid is False + assert result.error is X402Error.FACILITATOR_REJECTED + assert "unreachable" in result.message + + +def test_rejected_payment_never_executes_and_never_settles(): + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + facilitator = StubFacilitator( + FacilitatorVerdict(False, "invalid_exact_evm_signature", payer=PAYER) + ) + executions = [] + + relay = ActionRelay( + verifier=X402Verifier( + policy, facilitator=facilitator, payment_requirements=requirements() + ), + ledger=SettlementLedger(), + skill_executor=lambda request: executions.append(request) or {"success": True}, + ) + result = relay.handle_action( + ActionRequest( + action_id="act-forged-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=receipt_with(forged_authorization()), + ) + ) + + assert facilitator.calls == 1 + assert executions == [], "a rejected payment must not reach the simulator" + assert result.settlement_status == "skipped" + assert result.http_status in (400, 402) + + +def test_facilitator_verdict_is_required_to_be_explicitly_true(): + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + for verdict in ( + FacilitatorVerdict(False, "invalid_exact_evm_signature"), + FacilitatorVerdict(False, "", reachable=False), + ): + result = X402Verifier( + policy, facilitator=StubFacilitator(verdict), payment_requirements=requirements() + ).verify(receipt_with(forged_authorization())) + assert result.valid is False + + +# -- the real facilitator --------------------------------------------------- +@pytest.mark.facilitator +def test_live_facilitator_rejects_a_forged_authorization(): + """Drives https://x402.org/facilitator and expects a signature rejection.""" + verdict = FacilitatorClient().verify(forged_authorization(), requirements()) + + assert verdict.reachable, f"facilitator was unreachable: {verdict.reason}" + assert verdict.is_valid is False + assert "signature" in verdict.reason, verdict.reason + + +@pytest.mark.facilitator +def test_live_facilitator_rejection_blocks_the_whole_action(): + policy = PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + executions = [] + relay = ActionRelay( + verifier=X402Verifier( + policy, + facilitator=FacilitatorClient(), + payment_requirements=requirements(), + ), + ledger=SettlementLedger(), + skill_executor=lambda request: executions.append(request) or {"success": True}, + ) + result = relay.handle_action( + ActionRequest( + action_id="act-live-forged-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=receipt_with(forged_authorization()), + ) + ) + assert executions == [] + assert result.settlement_status == "skipped" + + +def test_requirements_are_built_from_the_profile(): + built = requirements() + assert built["maxAmountRequired"] == SKILL_PRICE_RAW + assert built["asset"] == USDC_BASE_SEPOLIA + assert built["payTo"] == PAYEE + assert DEFAULT_FACILITATOR_URL.startswith("https://") diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_idempotency.py b/bridge/boston_dynamics/atlas_bridge/tests/test_idempotency.py new file mode 100644 index 000000000..ce51c03e3 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_idempotency.py @@ -0,0 +1,241 @@ +"""A payment-validated action must actuate the robot exactly once — including after a restart. + +Replay protection on the payment is not the same guarantee: the same +idempotency key can arrive carrying a different payment, and an in-memory guard +forgets everything precisely when a client is most likely to retry. +""" + +from __future__ import annotations + +import json + +import pytest + +from bridge.boston_dynamics.atlas_bridge.bridge import AtlasActionHandler +from bridge.boston_dynamics.atlas_bridge.idempotency import ( + ConflictingRequest, + IdempotencyStore, +) + +ROBOT = "atlas-sim-01" +SKILL = "inspect_shelf" + + +def envelope( + action_id: str = "act-1", + idempotency_key: str = "idem-1", + params: dict | None = None, + payment: dict | None = None, +) -> bytes: + return json.dumps({ + "payload": { + "action": SKILL, + "skill_id": SKILL, + "robot_id": ROBOT, + "action_id": action_id, + "idempotency_key": idempotency_key, + "params": params if params is not None else {"maxDurationSec": 10}, + }, + "transaction_details": {"payment_payload": payment or {"txHash": "0x" + "a" * 64}}, + "timestamp": "2026-08-19T00:00:00Z", + }).encode("utf-8") + + +class Recorder: + def __init__(self) -> None: + self.messages: list[dict] = [] + + def __call__(self, payload: bytes) -> None: + self.messages.append(json.loads(payload.decode("utf-8"))) + + @property + def last(self) -> dict: + assert self.messages + return self.messages[-1] + + +def build(store: IdempotencyStore) -> tuple[AtlasActionHandler, Recorder, list]: + executions: list = [] + recorder = Recorder() + + def execute(max_duration_seconds: float, stop_requested=None) -> dict: + executions.append(max_duration_seconds) + return {"success": True, "status": "success", "targets_completed": 3, "targets_total": 3} + + handler = AtlasActionHandler( + recorder, execute=execute, synchronous=True, idempotency=store + ) + return handler, recorder, executions + + +# -- the core guarantee ----------------------------------------------------- +def test_same_key_actuates_the_robot_once(): + handler, recorder, executions = build(IdempotencyStore(path=None)) + + assert handler.handle(envelope(action_id="act-1")) == "executed" + assert handler.handle(envelope(action_id="act-2")) == "duplicate" + + assert len(executions) == 1, "the robot moved twice for one idempotency key" + assert recorder.last["result"]["error_code"] == "DUPLICATE_ACTION" + assert recorder.last["result"]["first_action_id"] == "act-1" + + +def test_guarantee_survives_a_restart(tmp_path): + """The store is reloaded from disk, so a retry after a crash is still one.""" + path = tmp_path / "idempotency.jsonl" + + first, _, first_runs = build(IdempotencyStore(path=path)) + assert first.handle(envelope(action_id="act-1")) == "executed" + assert len(first_runs) == 1 + + # A brand-new handler, as if the bridge had been restarted. + second, recorder, second_runs = build(IdempotencyStore(path=path)) + assert second.handle(envelope(action_id="act-2")) == "duplicate" + assert second_runs == [], "the robot moved again after a restart" + assert recorder.last["result"]["first_action_id"] == "act-1" + + +def test_different_keys_each_actuate(): + handler, _, executions = build(IdempotencyStore(path=None)) + assert handler.handle(envelope(action_id="act-1", idempotency_key="idem-1")) == "executed" + assert handler.handle(envelope(action_id="act-2", idempotency_key="idem-2")) == "executed" + assert len(executions) == 2 + + +# -- conflicts are refused, not silently replayed --------------------------- +def test_same_key_with_different_parameters_is_refused(): + handler, recorder, executions = build(IdempotencyStore(path=None)) + assert handler.handle(envelope(params={"maxDurationSec": 10})) == "executed" + assert handler.handle(envelope(action_id="act-2", params={"maxDurationSec": 20})) == "failure" + assert len(executions) == 1 + assert recorder.last["result"]["error_code"] == "IDEMPOTENCY_PARAMS_CONFLICT" + + +def test_same_key_with_a_different_payment_is_refused(): + handler, recorder, executions = build(IdempotencyStore(path=None)) + assert handler.handle(envelope(payment={"txHash": "0x" + "a" * 64})) == "executed" + assert handler.handle( + envelope(action_id="act-2", payment={"txHash": "0x" + "b" * 64}) + ) == "failure" + assert len(executions) == 1 + assert recorder.last["result"]["error_code"] == "IDEMPOTENCY_PAYMENT_CONFLICT" + + +# -- store behaviour -------------------------------------------------------- +def test_store_raises_on_conflicting_parameters(): + store = IdempotencyStore(path=None) + store.remember(ROBOT, SKILL, "idem-1", "hash-a", "pay-a", "act-1", "accepted") + assert store.check(ROBOT, SKILL, "idem-1", "hash-a", "pay-a").action_id == "act-1" + with pytest.raises(ConflictingRequest): + store.check(ROBOT, SKILL, "idem-1", "hash-b", "pay-a") + with pytest.raises(ConflictingRequest): + store.check(ROBOT, SKILL, "idem-1", "hash-a", "pay-b") + + +def test_store_scopes_keys_per_robot_and_skill(): + store = IdempotencyStore(path=None) + store.remember(ROBOT, SKILL, "idem-1", "h", "p", "act-1", "accepted") + assert store.check("another-robot", SKILL, "idem-1", "h", "p") is None + assert store.check(ROBOT, "stop", "idem-1", "h", "p") is None + + +def test_requests_without_a_key_are_not_deduplicated(): + """An absent idempotency key means the caller opted out; do not invent one.""" + store = IdempotencyStore(path=None) + assert store.check(ROBOT, SKILL, "", "h", "p") is None + assert store.remember(ROBOT, SKILL, "", "h", "p", "act-1", "accepted") is None + + +def test_a_truncated_store_does_not_break_startup(tmp_path): + path = tmp_path / "idempotency.jsonl" + path.write_text( + json.dumps({ + "robot_id": ROBOT, "skill_id": SKILL, "idempotency_key": "idem-1", + "params_hash": "h", "payment_fingerprint": "p", + "action_id": "act-1", "status": "accepted", + }) + "\n{ this line is truncated", + encoding="utf-8", + ) + store = IdempotencyStore(path=path) + assert len(store) == 1 + + +def test_concurrent_duplicates_actuate_once(): + """Two threads racing with the same key must not both move the robot. + + A check-then-record guard passes every sequential test and still lets two + simultaneous retries through, which is exactly when a client retries. + """ + import threading + + store = IdempotencyStore(path=None) + executions: list[float] = [] + barrier = threading.Barrier(8) + lock = threading.Lock() + + def execute(max_duration_seconds: float, stop_requested=None) -> dict: + with lock: + executions.append(max_duration_seconds) + return {"success": True, "status": "success", "targets_completed": 3, "targets_total": 3} + + def worker(index: int) -> None: + recorder = Recorder() + h = AtlasActionHandler(recorder, execute=execute, synchronous=True, idempotency=store) + barrier.wait() + h.handle(envelope(action_id=f"act-{index}", idempotency_key="idem-race")) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(executions) == 1, f"the robot moved {len(executions)} times for one key" + + +def test_claim_is_atomic(): + store = IdempotencyStore(path=None) + assert store.claim(ROBOT, SKILL, "idem-1", "h", "p", "act-1") is None + held = store.claim(ROBOT, SKILL, "idem-1", "h", "p", "act-2") + assert held is not None and held.action_id == "act-1" + + +def test_duplicate_is_answered_with_the_recorded_outcome(): + """A repeat must report how the original run ended, not just 'accepted'.""" + store = IdempotencyStore(path=None) + handler, recorder, executions = build(store) + + assert handler.handle(envelope(action_id="act-1")) == "executed" + assert handler.handle(envelope(action_id="act-2")) == "duplicate" + + assert len(executions) == 1 + assert recorder.last["result"]["first_action_id"] == "act-1" + assert recorder.last["result"]["first_status"] == "success", ( + "the duplicate reported the claim status instead of the run's outcome" + ) + + +def test_failed_run_is_recorded_as_failure(): + store = IdempotencyStore(path=None) + recorder = Recorder() + + def failing(max_duration_seconds: float, stop_requested=None) -> dict: + return {"success": False, "status": "failure"} + + handler = AtlasActionHandler( + recorder, execute=failing, synchronous=True, idempotency=store + ) + handler.handle(envelope(action_id="act-1")) + handler.handle(envelope(action_id="act-2")) + assert recorder.last["result"]["first_status"] == "failure" + + +def test_outcome_survives_a_restart(tmp_path): + path = tmp_path / "idempotency.jsonl" + first, _, _ = build(IdempotencyStore(path=path)) + first.handle(envelope(action_id="act-1")) + + second, recorder, runs = build(IdempotencyStore(path=path)) + second.handle(envelope(action_id="act-2")) + assert runs == [] + assert recorder.last["result"]["first_status"] == "success" diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_inspection_task.py b/bridge/boston_dynamics/atlas_bridge/tests/test_inspection_task.py new file mode 100644 index 000000000..685ffd4b0 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_inspection_task.py @@ -0,0 +1,199 @@ +"""The inspection skill must actually do the task, and say so honestly.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bridge.boston_dynamics.atlas_bridge.control_core import ( + ShelfInspectionController, +) +from bridge.boston_dynamics.atlas_bridge.mujoco_env import AtlasInspectionEnvironment +from bridge.boston_dynamics.atlas_bridge.runner import run_inspection +from bridge.boston_dynamics.atlas_bridge.task import ( + FALL_THRESHOLD_M, + INSPECTION_CHAIN, + INSPECTION_TARGETS, +) + + +@pytest.fixture(scope="module") +def episode() -> dict: + return run_inspection() + + +def test_every_target_is_reached_and_held(episode): + assert episode["targets_completed"] == len(INSPECTION_TARGETS) + assert all(entry["reached"] for entry in episode["policy_state"]["per_target"]) + + +def test_end_effector_accuracy_is_reported_and_tight(episode): + assert episode["max_position_error_m"] < 0.03 + for entry in episode["policy_state"]["per_target"]: + # ``best`` is the closest approach during the phase, ``final`` the error + # when the hold completed, so final can only be the larger of the two. + assert entry["final_error_m"] >= entry["best_error_m"] - 1e-9 + assert entry["final_error_m"] < 0.03 + + +def test_robot_stays_standing_on_its_own_feet(episode): + assert not episode["fall_detected"] + assert episode["min_pelvis_height_m"] > FALL_THRESHOLD_M + assert episode["base"].startswith("free-standing") + + +def test_no_collision_with_the_shelf(episode): + assert episode["shelf_contacts"] == 0 + + +def test_success_flag_agrees_with_the_measured_metrics(episode): + """``success`` must be derivable from the metrics, not asserted on its own.""" + derived = ( + episode["targets_completed"] == episode["targets_total"] + and not episode["fall_detected"] + and episode["shelf_contacts"] == 0 + and not episode["safe_stop_applied"] + ) + assert episode["success"] is derived + assert episode["status"] == ("success" if derived else "failure") + + +def test_run_is_bit_identical_across_repeats(): + """Repeated MuJoCo runs must be identical, not merely similar. + + The PR describes these runs as bit-identical, so the test hashes the whole + result rather than spot-checking a few metrics — a weaker check would let + the description claim more than the suite proves. + """ + import hashlib + import json + + def fingerprint() -> str: + result = run_inspection() + # Wall-clock time is the one field that legitimately varies. + result.pop("wall_time_seconds", None) + return hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest() + + digests = {fingerprint() for _ in range(3)} + assert len(digests) == 1, f"MuJoCo runs diverged: {digests}" + + +def test_controller_is_closed_loop_not_a_replayed_trajectory(): + """Feeding a different measured pose must produce different commands.""" + environment = AtlasInspectionEnvironment() + limits = environment.joint_limits() + + left = ShelfInspectionController() + right = ShelfInspectionController() + left.reset(limits) + right.reset(limits) + + jacobian = np.zeros((3, len(INSPECTION_CHAIN))) + jacobian[0, 0] = jacobian[1, 1] = jacobian[2, 2] = 1.0 + + for _ in range(500): + left.step(np.array([0.40, -0.56, 0.96]), jacobian, 0.0) + right.step(np.array([0.10, -0.20, 0.60]), jacobian, 0.0) + + near = left.step(np.array([0.40, -0.56, 0.96]), jacobian, 0.0) + far = right.step(np.array([0.10, -0.20, 0.60]), jacobian, 0.0) + assert near.joint_targets != far.joint_targets + assert near.position_error_m != far.position_error_m + + +def test_safe_stop_halts_the_robot_mid_episode(): + calls = {"n": 0} + + def stop_after_600() -> bool: + calls["n"] += 1 + return calls["n"] > 600 + + result = run_inspection(stop_requested=stop_after_600) + assert result["safe_stop_applied"] is True + assert result["completion_reason"] == "safe_stopped" + assert result["success"] is False + + +def test_episode_respects_its_time_budget(): + result = run_inspection(max_duration_seconds=1.0) + assert result["sim_duration_seconds"] <= 1.05 + assert result["success"] is False + + +def test_targets_stay_inside_the_validated_reach_core(): + """Every target must sit in the block the reach sweep proved usable. + + This is a real regression guard: moving a target a few centimetres outside + the measured core is enough to make Atlas lean into the shelf and topple, + and the failure looks like a controller bug rather than a geometry change. + """ + from bridge.boston_dynamics.atlas_bridge.task import ( + HOME_END_EFFECTOR, + INSPECTION_TARGETS, + ) + + forward_low, forward_high = 0.06, 0.18 + vertical_low, vertical_high = -0.12, 0.20 + + for target in INSPECTION_TARGETS: + forward = target.x - HOME_END_EFFECTOR[0] + vertical = target.z - HOME_END_EFFECTOR[2] + assert forward_low <= forward <= forward_high, ( + f"{target.name}: {forward:.3f} m forward is outside the validated core" + ) + assert vertical_low <= vertical <= vertical_high, ( + f"{target.name}: {vertical:.3f} m vertical is outside the validated core" + ) + + +# -- the reported speed must be the speed of the hand ------------------------ +def test_reported_speed_matches_the_hand_actually_moving(): + """Guards the metric itself, not the motion. + + The first version of this metric read ``data.cvel[hand][:3]``. MuJoCo lays + ``cvel`` out as ``[angular; linear]``, so that reported the hand's angular + rate in rad/s as a speed in m/s — 5.76 where the hand was moving at 1.13. + Nothing in the task failed, which is exactly why it survived: the only way + to catch it is to check the number against the hand's own displacement. + """ + from bridge.boston_dynamics.atlas_bridge.episode import run_episode + + environment = AtlasInspectionEnvironment() + samples: list[float] = [] + previous: list = [None] + + def record(_step: int, _observation: dict, _plan) -> None: + hand = environment.end_effector() + if previous[0] is not None: + samples.append( + float(np.linalg.norm(hand - previous[0])) / environment.control_timestep + ) + previous[0] = hand + + metrics = run_episode(environment, engine="MuJoCo", on_step=record) + + assert samples, "the episode produced no steps to measure" + assert metrics["max_end_effector_speed_mps"] == pytest.approx(max(samples), abs=1e-3) + + +def test_the_arm_inspects_slowly_even_though_the_episode_peak_is_higher(): + """The peak is a RETURN artefact; near the shelf the arm is slow. + + ``RETURN`` assigns the stance pose straight into the joint targets, so the + servo rate limit that shapes ``REACH`` does not apply and only the actuator + limits bound the retraction. That peak is not what a reviewer asking about + inspection speed is asking about, so the two are reported separately and + the one that touches the shelf is the one held to a bound. + """ + from bridge.boston_dynamics.atlas_bridge.pybullet_runner import ( + run_inspection as run_pybullet_inspection, + ) + + metrics = run_pybullet_inspection() + assert metrics["status"] == "success" + assert metrics["shelf_contacts"] == 0 + assert metrics["max_end_effector_speed_inspecting_mps"] < 1.0 + assert ( + metrics["max_end_effector_speed_inspecting_mps"] + <= metrics["max_end_effector_speed_mps"] + ) diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_kinematics.py b/bridge/boston_dynamics/atlas_bridge/tests/test_kinematics.py new file mode 100644 index 000000000..4a499c233 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_kinematics.py @@ -0,0 +1,107 @@ +"""The shared kinematics must agree with the physics engine's own. + +The controller uses one URDF-derived Jacobian in every simulator so that the +"same controller everywhere" claim is literally true. That only holds if this +Jacobian is correct, so it is checked against MuJoCo's independently computed +one here. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from bridge.boston_dynamics.atlas_bridge import kinematics +from bridge.boston_dynamics.atlas_bridge.mujoco_env import AtlasInspectionEnvironment +from bridge.boston_dynamics.atlas_bridge.task import ( + END_EFFECTOR_BODY, + INSPECTION_CHAIN, + STANCE_POSE, +) + + +@pytest.fixture(scope="module") +def settled() -> AtlasInspectionEnvironment: + environment = AtlasInspectionEnvironment() + environment.reset(dict(STANCE_POSE)) + for _ in range(1200): + environment.step(dict(STANCE_POSE)) + return environment + + +def test_chain_reaches_the_end_effector(): + chain = kinematics.chain_to_end_effector() + assert chain[-1].child == END_EFFECTOR_BODY + names = {link.name for link in chain} + assert set(INSPECTION_CHAIN) <= names + + +def test_forward_kinematics_matches_mujoco(settled): + angles = settled.joint_angles() + predicted = kinematics.end_effector_position( + angles, + base_position=settled.data.xpos[settled.pelvis_id], + base_rotation=settled.base_rotation(), + ) + measured = settled.end_effector() + assert np.linalg.norm(predicted - measured) < 0.002 + + +def test_jacobian_matches_mujoco(settled): + ours = kinematics.jacobian( + settled.joint_angles(), base_rotation=settled.base_rotation() + ) + theirs = settled.engine_jacobian() + assert ours.shape == (3, len(INSPECTION_CHAIN)) + # The measured worst case over an episode is 2.4e-4; the bound is kept just + # above it so the PR description and this test cannot drift apart. + assert np.max(np.abs(ours - theirs)) < 5e-4 + + +def test_jacobian_tracks_configuration_changes(settled): + """A different arm configuration has to give a different Jacobian.""" + angles = settled.joint_angles() + moved = dict(angles) + moved["r_arm_ely"] = angles["r_arm_ely"] + 0.5 + assert not np.allclose(kinematics.jacobian(angles), kinematics.jacobian(moved)) + + +def test_unknown_chain_joint_is_rejected(settled): + with pytest.raises(KeyError): + kinematics.jacobian(settled.joint_angles(), chain=("l_leg_kny",)) + + +def test_gravity_model_matches_mujoco(settled): + """The shared gravity feedforward must equal MuJoCo's own bias term. + + Every backend uses this model — MuJoCo has ``qfrc_bias`` and PyBullet has + inverse dynamics, but Webots has neither, so the feedforward is computed from + the URDF instead. That is only legitimate while it agrees with an engine + that computes it independently. + """ + import mujoco + + settled.data.qvel[:] = 0.0 + mujoco.mj_forward(settled.model, settled.data) + theirs = settled.data.qfrc_bias[settled.actuators.qvel_addresses] + + ours = kinematics.gravity_torques( + settled.joint_angles(), base_rotation=settled.base_rotation() + ) + for name, expected in zip(settled.actuators.names, theirs): + assert ours[name] == pytest.approx(float(expected), abs=1e-3) + + +def test_gravity_model_covers_every_actuated_joint(settled): + torques = kinematics.gravity_torques(settled.joint_angles()) + assert set(settled.actuators.names) <= set(torques) + + +def test_gravity_model_responds_to_configuration(settled): + """An extended arm must load the shoulder more than a tucked one.""" + tucked = settled.joint_angles() + extended = dict(tucked) + extended["r_arm_shx"] = 0.0 # arm swung out horizontally + assert abs(kinematics.gravity_torques(extended)["r_arm_shx"]) > abs( + kinematics.gravity_torques(tucked)["r_arm_shx"] + ) diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_model_integrity.py b/bridge/boston_dynamics/atlas_bridge/tests/test_model_integrity.py new file mode 100644 index 000000000..b2aa2f3c5 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_model_integrity.py @@ -0,0 +1,111 @@ +"""The robot must be the pinned Atlas v4, and the code must address it correctly. + +These tests exist because of a real defect: an earlier revision kept a +hand-written actuator order that disagreed with the compiled model, so 27 of 30 +control channels were cross-wired. Anything that could let that happen again is +asserted here. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import mujoco +import pytest + +from bridge.boston_dynamics.atlas_bridge import actuators, model +from bridge.boston_dynamics.atlas_bridge.mujoco_env import AtlasInspectionEnvironment +from bridge.boston_dynamics.atlas_bridge.task import ( + END_EFFECTOR_BODY, + HOME_END_EFFECTOR, + HOME_PELVIS_HEIGHT_M, + INSPECTION_CHAIN, + STANCE_POSE, +) + +LOCK = json.loads( + (Path(model.__file__).parent / "models" / "model.lock.json").read_text(encoding="utf-8") +)["atlas_v4"] + + +def test_model_source_is_pinned_and_licensed(): + assert LOCK["commit"] == "d32bcb2b35b94168b5ce27233ca62f3c8678886f" + assert LOCK["license"] == "MIT" + assert LOCK["source"].endswith("roboschool") + + +def test_no_model_assets_are_vendored(): + """The description is fetched, never committed.""" + tracked = Path(model.__file__).parent / "models" + committed = [p for p in tracked.rglob("*") if p.is_file() and p.name != "model.lock.json"] + vendored = [p for p in committed if "atlas_v4" not in p.parts] + assert vendored == [], f"unexpected vendored model assets: {vendored}" + + +def test_actuator_map_matches_the_urdf(): + """Effort limits and joint set come from the URDF, not from a copy.""" + compiled = mujoco.MjModel.from_xml_string(model.scene_xml()) + mapped = actuators.validate(compiled, model.joint_efforts()) + assert len(mapped) == 30 + # Values below are the upstream Atlas v4 URDF's own effort limits. They are + # asserted rather than re-declared anywhere in the bridge: the previous + # revision hard-coded a different (v5) table and silently disagreed with the + # model it was driving. + assert mapped.effort_limits[mapped.index("l_leg_kny")] == pytest.approx(890.0) + assert mapped.effort_limits[mapped.index("l_leg_hpy")] == pytest.approx(840.0) + assert mapped.effort_limits[mapped.index("l_leg_aky")] == pytest.approx(92.0) + assert mapped.effort_limits[mapped.index("r_arm_elx")] == pytest.approx(112.0) + + +def test_actuator_validation_fails_loudly_on_drift(): + """A joint-set or effort mismatch must raise, never pass silently.""" + compiled = mujoco.MjModel.from_xml_string(model.scene_xml()) + drifted = dict(model.joint_efforts()) + drifted["l_leg_kny"] = 80.0 + with pytest.raises(ValueError, match="Effort limit drift"): + actuators.validate(compiled, drifted) + + missing = dict(model.joint_efforts()) + missing.pop("r_arm_elx") + with pytest.raises(ValueError, match="does not match the pinned URDF"): + actuators.validate(compiled, missing) + + +def test_control_vector_is_addressed_by_name_not_position(): + """``vector`` must place each value on that joint's own ctrl channel.""" + compiled = mujoco.MjModel.from_xml_string(model.scene_xml()) + mapped = actuators.build(compiled) + command = mapped.vector({"l_leg_kny": 0.62, "r_arm_elx": -1.2}) + assert command[mapped.index("l_leg_kny")] == pytest.approx(0.62) + assert command[mapped.index("r_arm_elx")] == pytest.approx(-1.2) + assert command[mapped.index("back_bkz")] == pytest.approx(0.0) + + +def test_unknown_joint_names_are_rejected(): + compiled = mujoco.MjModel.from_xml_string(model.scene_xml()) + mapped = actuators.build(compiled) + with pytest.raises(KeyError): + mapped.vector({"not_an_atlas_joint": 1.0}) + + +def test_inspection_chain_and_end_effector_exist_on_the_robot(): + compiled = mujoco.MjModel.from_xml_string(model.scene_xml()) + names = set(actuators.build(compiled).names) + assert set(INSPECTION_CHAIN) <= names + assert set(STANCE_POSE) <= names + assert mujoco.mj_name2id(compiled, mujoco.mjtObj.mjOBJ_BODY, END_EFFECTOR_BODY) >= 0 + + +def test_home_pose_matches_the_recorded_geometry(): + """Guards the shelf coordinates against silent model drift.""" + environment = AtlasInspectionEnvironment() + environment.reset(dict(STANCE_POSE)) + for _ in range(1200): + environment.step(dict(STANCE_POSE)) + hand = environment.end_effector() + for measured, recorded in zip(hand, HOME_END_EFFECTOR): + assert measured == pytest.approx(recorded, abs=0.02) + assert environment.observe()["pelvis_height"] == pytest.approx( + HOME_PELVIS_HEIGHT_M, abs=0.02 + ) diff --git a/bridge/boston_dynamics/atlas_bridge/tests/test_x402_payment_safety.py b/bridge/boston_dynamics/atlas_bridge/tests/test_x402_payment_safety.py new file mode 100644 index 000000000..bd56b5afc --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/tests/test_x402_payment_safety.py @@ -0,0 +1,463 @@ +"""x402 payment safety tests. + +Proves: +1. Unpaid request → HTTP 402 → no execution → no settlement +2. Invalid payment → rejected → no execution → no settlement +3. Valid payment → execution → success → settlement approved +4. Valid payment → execution → failure → NO settlement +5. Replay detection → rejected → no settlement +""" + +from __future__ import annotations + +import time + +import pytest + + +from bridge.boston_dynamics.atlas_bridge.x402 import ( + X402Verifier, + X402Error, + PaymentPolicy, +) +from bridge.boston_dynamics.atlas_bridge.payment import ( + SettlementLedger, + SettlementStatus, +) +from bridge.boston_dynamics.atlas_bridge.relay import ( + ActionRelay, + ActionRequest, +) + + +def _valid_receipt( + amount: str = "10000", + asset: str = "USDC", + network: str = "eip155:84532", + tx_hash: str = "0x372323a755883be6a4feeda46a9266b9c0c310782018b73fc0639bcb764a557b", +) -> dict: + return { + "amount": amount, + "asset": asset, + "network": network, + "txHash": tx_hash, + "payer": "0xPayer", + "payee": "0xPayee", + } + + +def _make_policy() -> PaymentPolicy: + return PaymentPolicy( + network="eip155:84532", + asset="USDC", + amount="10000", + settle_on_failure=False, + replay_protection=True, + ) + + +class TestX402Verification: + def test_missing_payment_returns_402(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify(None) + assert not result.valid + assert result.error == X402Error.MISSING_PAYMENT + + def test_malformed_json_rejected(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify("not-json{{{") + assert not result.valid + assert result.error == X402Error.INVALID_FORMAT + + def test_missing_tx_hash_rejected(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify({"amount": "10000", "network": "eip155:84532"}) + assert not result.valid + assert result.error == X402Error.INVALID_FORMAT + + def test_amount_mismatch_rejected(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify(_valid_receipt(amount="5000")) + assert not result.valid + assert result.error == X402Error.AMOUNT_MISMATCH + + def test_network_mismatch_rejected(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify(_valid_receipt(network="eip155:1")) + assert not result.valid + assert result.error == X402Error.NETWORK_MISMATCH + + def test_asset_mismatch_rejected(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify(_valid_receipt(asset="USDT")) + assert not result.valid + assert result.error == X402Error.ASSET_MISMATCH + + def test_valid_receipt_accepted(self): + verifier = X402Verifier(_make_policy()) + result = verifier.verify(_valid_receipt()) + assert result.valid + assert result.receipt is not None + assert result.receipt.tx_hash == "0x372323a755883be6a4feeda46a9266b9c0c310782018b73fc0639bcb764a557b" + + def test_replay_detected(self): + verifier = X402Verifier(_make_policy()) + first = verifier.verify(_valid_receipt(tx_hash="0x71a31d7f4889b96c8d8e834fad08c6251221ebfd23fd0eadf14ebc89b25110bf")) + assert first.valid + second = verifier.verify(_valid_receipt(tx_hash="0x71a31d7f4889b96c8d8e834fad08c6251221ebfd23fd0eadf14ebc89b25110bf")) + assert not second.valid + assert second.error == X402Error.REPLAY_DETECTED + + def test_different_tx_hashes_accepted(self): + verifier = X402Verifier(_make_policy()) + first = verifier.verify(_valid_receipt(tx_hash="0x8d28a34ccbea2aedf5f06c61d746cb3de86dfa97066d132c3e98875950d0816a")) + assert first.valid + second = verifier.verify(_valid_receipt(tx_hash="0x8ecfa169fd5234efac72555985bfcb096d2aa631ce0a9780669fa77e4d10272e")) + assert second.valid + + def test_expired_receipt_rejected(self): + verifier = X402Verifier(_make_policy()) + receipt = _valid_receipt() + receipt["expiry"] = time.time() - 100 + result = verifier.verify(receipt) + assert not result.valid + assert result.error == X402Error.EXPIRED + + +class TestSettlementLedger: + def test_unpaid_records_skipped(self): + ledger = SettlementLedger() + entry = ledger.record_unpaid("act-1", "inspect_shelf", "atlas-01") + assert entry.status == SettlementStatus.SKIPPED_UNPAID + + def test_settled_requires_a_real_transaction(self): + """SETTLED means money moved; a receipt alone must not earn it. + + The receipt the caller presents authorises the run. It is not the + settlement transaction, and a ledger reporting one as the other + publishes an artifact claiming a transfer that never happened. + """ + ledger = SettlementLedger() + ledger.record_execution_start("act-1", "navigate", "atlas", "0xd0ef2db67c3551d36f0ad1420a5853c7464e9a68f6dd5f4aa7ae76eb2f824481", "10000", "USDC", "eip155:84532") + entry = ledger.settle_on_success( + "act-1", block_number=12345, settlement_tx_hash="0x" + "ab" * 32 + ) + assert entry.status == SettlementStatus.SETTLED + assert entry.block_number == 12345 + assert entry.settlement_tx_hash == "0x" + "ab" * 32 + + def test_success_without_a_transaction_is_only_eligible(self): + ledger = SettlementLedger() + ledger.record_execution_start("act-1", "navigate", "atlas", "0xd0ef2db67c3551d36f0ad1420a5853c7464e9a68f6dd5f4aa7ae76eb2f824481", "10000", "USDC", "eip155:84532") + entry = ledger.settle_on_success("act-1", block_number=12345) + assert entry.status == SettlementStatus.SETTLEMENT_ELIGIBLE + assert entry.settlement_tx_hash == "" + assert entry.block_number == 0, "an eligible entry must not carry a block" + assert entry.execution_success is True + + def test_skip_on_failure(self): + ledger = SettlementLedger() + ledger.record_execution_start("act-1", "navigate", "atlas", "0xd0ef2db67c3551d36f0ad1420a5853c7464e9a68f6dd5f4aa7ae76eb2f824481", "10000", "USDC", "eip155:84532") + entry = ledger.skip_on_failure("act-1", reason="Robot fell") + assert entry is not None + assert entry.status == SettlementStatus.SKIPPED_FAILURE + assert entry.execution_success is False + + def test_no_double_settle(self): + ledger = SettlementLedger() + ledger.record_execution_start("act-1", "navigate", "atlas", "0xd0ef2db67c3551d36f0ad1420a5853c7464e9a68f6dd5f4aa7ae76eb2f824481", "10000", "USDC", "eip155:84532") + first = ledger.settle_on_success("act-1", block_number=7, + settlement_tx_hash="0x" + "cd" * 32) + assert first.status == SettlementStatus.SETTLED + second = ledger.settle_on_success("act-1", block_number=8, + settlement_tx_hash="0x" + "ef" * 32) + assert second is not None + assert second.status == SettlementStatus.SETTLED + assert second.settlement_tx_hash == "0x" + "cd" * 32, "the action settled twice" + assert ledger.to_dict()["settled_on_chain"] == 1 + + def test_settle_on_success_only(self): + ledger = SettlementLedger() + ledger.record_execution_start("act-1", "navigate", "atlas", "0xd0ef2db67c3551d36f0ad1420a5853c7464e9a68f6dd5f4aa7ae76eb2f824481", "10000", "USDC", "eip155:84532") + ledger.skip_on_failure("act-1", reason="execution failed") + entry = ledger.get_entry("act-1") + assert entry.status == SettlementStatus.SKIPPED_FAILURE + assert entry.execution_success is False + + def test_ledger_to_dict(self): + ledger = SettlementLedger() + ledger.record_unpaid("act-1", "nav", "atlas") + ledger.record_execution_start("act-2", "nav", "atlas", "0xh", "10000", "USDC", "eip155:84532") + ledger.settle_on_success("act-2") + d = ledger.to_dict() + assert d["total"] == 2 + assert d["settled_on_chain"] == 0, "nothing moved on chain in this ledger" + assert d["settlement_eligible_not_on_chain"] == 1 + assert d["skipped_unpaid"] == 1 + assert d["entries"][1]["settlement_tx_hash"] is None + assert d["entries"][1]["settled_on_chain"] is False + + +class TestRelayPaymentGating: + def _make_relay(self, executor_result: dict | None = None): + if executor_result is None: + executor_result = {"success": True, "forward_progress_m": 4.129} + ledger = SettlementLedger() + verifier = X402Verifier(_make_policy()) + relay = ActionRelay( + verifier=verifier, + ledger=ledger, + skill_executor=lambda req: executor_result, + ) + return relay, ledger + + def test_unpaid_request_returns_402(self): + relay, ledger = self._make_relay() + request = ActionRequest( + action_id="act-unpaid-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={"maxDurationSec": 48}, + payment_header=None, + ) + result = relay.handle_action(request) + assert result.http_status == 402 + assert result.status == "error" + assert result.settlement_status == "skipped" + entries = ledger.get_all() + assert len(entries) == 1 + assert entries[0].status == SettlementStatus.SKIPPED_UNPAID + + def test_invalid_payment_returns_400(self): + relay, ledger = self._make_relay() + request = ActionRequest( + action_id="act-invalid-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header={"amount": "5000", "network": "eip155:84532", "txHash": "0x7b32195338c9901877c850d2f90e1687f6ee58e516f75840100feece525a4b4d"}, + ) + result = relay.handle_action(request) + assert result.http_status == 400 + assert result.settlement_status == "skipped" + + def test_valid_payment_success_settles(self): + relay, ledger = self._make_relay({"success": True, "distance": 4.129}) + request = ActionRequest( + action_id="act-success-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0xab2510103273047af570a0a59f64490a34d89eeac430a6edc132f0048e2ca28b"), + ) + result = relay.handle_action(request) + assert result.http_status == 200 + assert result.status == "success" + # The relay holds no wallet, so a successful run is eligible for + # settlement rather than settled. real_paid_run.py is the path that + # actually moves USDC. + assert result.settlement_status == "settlement_eligible" + entry = ledger.get_entry("act-success-1") + assert entry.status == SettlementStatus.SETTLEMENT_ELIGIBLE + + def test_valid_payment_failure_no_settlement(self): + relay, ledger = self._make_relay({"success": False, "error_code": "FALL_DETECTED"}) + request = ActionRequest( + action_id="act-fail-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0xc43a2190c3babd18ac33f957d43deee3a97ab4a8bcc32fa37ceef1a52e2de61a"), + ) + result = relay.handle_action(request) + assert result.http_status == 200 + assert result.status == "error" + assert result.settlement_status == "skipped_failure" + entry = ledger.get_entry("act-fail-1") + assert entry.status == SettlementStatus.SKIPPED_FAILURE + assert entry.execution_success is False + + def test_execution_exception_no_settlement(self): + def failing_executor(req): + raise RuntimeError("Simulator crashed") + + ledger = SettlementLedger() + relay = ActionRelay( + verifier=X402Verifier(_make_policy()), + ledger=ledger, + skill_executor=failing_executor, + ) + request = ActionRequest( + action_id="act-crash-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0x827c153165a3b65b94c88de9a25a8a38a51dc2864c2141d470b925bfe5234255"), + ) + result = relay.handle_action(request) + assert result.http_status == 500 + assert result.settlement_status == "skipped_failure" + + def test_robot_id_mismatch_rejected(self): + relay, ledger = self._make_relay() + request = ActionRequest( + action_id="act-mismatch-1", + robot_id="wrong-robot", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0xc25088046929a7308634ac3c50bdb866464a8f01d3c1180f2f4f9a0f5c7168cf"), + ) + result = relay.handle_action(request) + assert result.http_status == 400 + + def test_replay_rejected(self): + relay, ledger = self._make_relay() + request1 = ActionRequest( + action_id="act-replay-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0x8b644a37e49f8a08f2079dc3a2343b9d8d8adf86825b2c4f18d81660a3f00581"), + ) + relay.handle_action(request1) + request2 = ActionRequest( + action_id="act-replay-2", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0x8b644a37e49f8a08f2079dc3a2343b9d8d8adf86825b2c4f18d81660a3f00581"), + ) + result = relay.handle_action(request2) + assert result.http_status == 409 + assert result.settlement_status == "skipped" + + def test_relay_full_flow_unpaid_then_paid(self): + relay, ledger = self._make_relay({"success": True}) + req_unpaid = ActionRequest( + action_id="act-flow-1", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=None, + ) + r1 = relay.handle_action(req_unpaid) + assert r1.http_status == 402 + + req_paid = ActionRequest( + action_id="act-flow-2", + robot_id="atlas-sim-01", + skill_id="inspect_shelf", + params={}, + payment_header=_valid_receipt(tx_hash="0x57fd4f06b62312cd7f06d54a585dc1173548cc4804a4a9e84b15fcbfcd9ff54a"), + ) + r2 = relay.handle_action(req_paid) + assert r2.http_status == 200 + assert r2.settlement_status == "settlement_eligible" + + +class TestPaymentContractConsistency: + """The price and the asset must be one decision, not four copies.""" + + def _profile_dir(self): + from pathlib import Path + + from bridge.boston_dynamics.atlas_bridge import bridge as bridge_module + + return ( + Path(bridge_module.__file__).resolve().parents[3] + / "registry" / "vendors" / "boston-dynamics" / "atlas" + / "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" + ) + + def test_registry_price_matches_the_bridge_price(self): + import yaml + + from bridge.boston_dynamics.atlas_bridge.task import SKILL_PRICE_USDC + + policy = yaml.safe_load((self._profile_dir() / "payment-policy.yaml").read_text(encoding="utf-8")) + skills = yaml.safe_load((self._profile_dir() / "skills.yaml").read_text(encoding="utf-8")) + for entry in policy["policies"]: + assert entry["priceUSDC"] == SKILL_PRICE_USDC + for entry in skills["skills"]: + assert entry["priceUSDC"] == SKILL_PRICE_USDC + + def test_raw_price_is_the_decimal_price(self): + from decimal import Decimal + + from bridge.boston_dynamics.atlas_bridge.task import ( + SKILL_PRICE_RAW, + SKILL_PRICE_USDC, + USDC_DECIMALS, + ) + + assert Decimal(SKILL_PRICE_RAW) == Decimal(SKILL_PRICE_USDC) * (10**USDC_DECIMALS) + + def test_registry_asset_matches_the_bridge_asset(self): + import yaml + + from bridge.boston_dynamics.atlas_bridge.task import ( + PAYMENT_NETWORK, + USDC_BASE_SEPOLIA, + ) + + policy = yaml.safe_load((self._profile_dir() / "payment-policy.yaml").read_text(encoding="utf-8")) + assert policy["asset"]["address"] == USDC_BASE_SEPOLIA + assert policy["network"] == PAYMENT_NETWORK + + def test_settlement_layers_share_one_asset_address(self): + from bridge.boston_dynamics.atlas_bridge.real_paid_run import USDC_BASE_SEPOLIA as paying + from bridge.boston_dynamics.atlas_bridge.settlement_evidence import USDC_ADDRESS as evidence + from bridge.boston_dynamics.atlas_bridge.task import USDC_BASE_SEPOLIA as declared + + assert paying == evidence == declared + + +class TestTransactionHashValidation: + """A settlement reference must look like a settlement reference.""" + + def _verifier(self): + from bridge.boston_dynamics.atlas_bridge.task import ( + PAYMENT_NETWORK, + SKILL_PRICE_RAW, + ) + from bridge.boston_dynamics.atlas_bridge.x402 import PaymentPolicy, X402Verifier + + return X402Verifier( + PaymentPolicy(network=PAYMENT_NETWORK, asset="USDC", amount=SKILL_PRICE_RAW) + ) + + def _receipt(self, tx_hash: str) -> dict: + from bridge.boston_dynamics.atlas_bridge.task import ( + PAYMENT_NETWORK, + SKILL_PRICE_RAW, + ) + + return { + "amount": SKILL_PRICE_RAW, + "asset": "USDC", + "network": PAYMENT_NETWORK, + "txHash": tx_hash, + } + + @pytest.mark.parametrize( + "tx_hash", + [ + "not-a-hash", + "0xdemo_real_tx_abc123", + "0xabc123", + "0x" + "f" * 63, + "0x" + "f" * 65, + "f" * 64, + "", + ], + ) + def test_malformed_transaction_hash_is_rejected(self, tx_hash): + from bridge.boston_dynamics.atlas_bridge.x402 import X402Error + + result = self._verifier().verify(self._receipt(tx_hash)) + assert result.valid is False + assert result.error in {X402Error.MALFORMED_TX_HASH, X402Error.INVALID_FORMAT} + + def test_well_formed_transaction_hash_is_accepted(self): + result = self._verifier().verify(self._receipt("0x" + "a1" * 32)) + assert result.valid is True diff --git a/bridge/boston_dynamics/atlas_bridge/visual_evidence.py b/bridge/boston_dynamics/atlas_bridge/visual_evidence.py new file mode 100644 index 000000000..411c37880 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/visual_evidence.py @@ -0,0 +1,178 @@ +"""Render the MuJoCo inspection episode to an annotated animated GIF. + +The GIF is produced from the same episode the metrics come from, and every frame +carries the live numbers — phase, active target, end-effector error, targets +completed, pelvis height — so the picture and the evidence cannot disagree. + +Green spheres mark the three inspection targets at their tolerance radius. They +are non-colliding markers; :func:`render_episode` asserts that the annotated +episode produces identical metrics to a plain one before writing anything. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import mujoco + +from .control_core import ShelfInspectionController +from .episode import run_episode +from .kinematics import jacobian +from .mujoco_env import AtlasInspectionEnvironment +from .task import EPISODE_BUDGET_S, FALL_THRESHOLD_M, INSPECTION_TARGETS + +FRAME_WIDTH = 640 +FRAME_HEIGHT = 480 +#: One rendered frame per this many control steps (2 ms each). +FRAME_STRIDE = 32 +GIF_FRAME_MS = 70 +#: Shared adaptive palette size for the GIF. +GIF_PALETTE_COLORS = 64 +#: Metrics that must not change when the markers are added. +COMPARED_METRICS = ( + "status", + "targets_completed", + "mean_position_error_m", + "max_position_error_m", + "min_pelvis_height_m", + "shelf_contacts", + "control_steps", + "sim_duration_seconds", +) + + +def _camera() -> mujoco.MjvCamera: + """Framed on the working volume so the arm motion is actually visible.""" + camera = mujoco.MjvCamera() + camera.lookat[:] = (0.26, -0.46, 0.98) + camera.distance = 2.55 + camera.azimuth = -128 + camera.elevation = -10 + return camera + + +def _annotate(image, lines: list[tuple[str, str]]): + """Draw a compact metric panel in the corner of a frame.""" + from PIL import ImageDraw + + draw = ImageDraw.Draw(image, "RGBA") + padding, leading = 10, 15 + height = leading * len(lines) + padding * 2 + draw.rectangle([(0, 0), (250, height)], fill=(12, 14, 18, 205)) + for index, (label, value) in enumerate(lines): + y = padding + index * leading + draw.text((padding, y), label, fill=(150, 158, 170)) + draw.text((padding + 118, y), value, fill=(236, 240, 246)) + return image + + +def render_episode( + destination: Path, max_duration_seconds: float = EPISODE_BUDGET_S +) -> tuple[Path, dict]: + """Run one episode, capture annotated frames, and write the GIF.""" + from PIL import Image + + environment = AtlasInspectionEnvironment(show_targets=True) + controller = ShelfInspectionController(budget_seconds=max_duration_seconds) + observation = environment.reset(controller.reset(environment.joint_limits())) + + renderer = mujoco.Renderer(environment.model, height=FRAME_HEIGHT, width=FRAME_WIDTH) + camera = _camera() + frames: list[Image.Image] = [] + steps = 0 + + while observation["sim_time"] < max_duration_seconds: + angles = environment.joint_angles() + plan = controller.step( + environment.end_effector(), + jacobian(angles, base_rotation=environment.base_rotation()), + observation["sim_time"], + angles, + ) + observation = environment.step(plan.joint_targets) + steps += 1 + if steps % FRAME_STRIDE == 0: + renderer.update_scene(environment.data, camera) + frames.append( + _annotate( + Image.fromarray(renderer.render()), + [ + ("Atlas v4", "MuJoCo"), + ("phase", plan.phase), + ("target", plan.active_target), + ("error", f"{plan.position_error_m * 1000:6.1f} mm"), + ("completed", f"{plan.targets_completed}/{len(INSPECTION_TARGETS)}"), + ("pelvis", f"{observation['pelvis_height']:.3f} m"), + ("fall below", f"{FALL_THRESHOLD_M:.2f} m"), + ("shelf hits", str(environment.shelf_contacts)), + ], + ) + ) + if environment.fall_detected or controller.finished: + break + + renderer.close() + if not frames: + raise RuntimeError("No frames were rendered") + + plain = run_episode( + AtlasInspectionEnvironment(), engine="MuJoCo", + max_duration_seconds=max_duration_seconds, + ) + annotated = run_episode( + AtlasInspectionEnvironment(show_targets=True), engine="MuJoCo", + max_duration_seconds=max_duration_seconds, + ) + drift = [key for key in COMPARED_METRICS if plain[key] != annotated[key]] + if drift: + raise RuntimeError(f"Target markers changed the episode: {drift}") + + destination.parent.mkdir(parents=True, exist_ok=True) + # One shared adaptive palette keeps the file small and avoids per-frame + # colour flicker. + palette = frames[0].quantize(colors=GIF_PALETTE_COLORS, method=Image.MEDIANCUT) + quantized = [frame.quantize(palette=palette, dither=Image.FLOYDSTEINBERG) for frame in frames] + quantized[0].save( + destination, + save_all=True, + append_images=quantized[1:], + duration=GIF_FRAME_MS, + loop=0, + optimize=True, + ) + # Keep the artefact small enough to load inline in a pull request. + if destination.stat().st_size > 4_000_000: + raise RuntimeError(f"GIF is too large: {destination.stat().st_size} bytes") + + return destination, { + "frames": len(frames), + "control_steps": steps, + "metrics_match_plain_run": True, + "targets_completed": plain["targets_completed"], + "targets_total": plain["targets_total"], + "mean_position_error_m": plain["mean_position_error_m"], + "min_pelvis_height_m": plain["min_pelvis_height_m"], + "shelf_contacts": plain["shelf_contacts"], + "fall_detected": plain["fall_detected"], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render the inspection episode to a GIF.") + parser.add_argument( + "--output", + type=Path, + default=Path("docs/evidence/atlas-shelf-inspection.gif"), + ) + parser.add_argument("--max-duration", type=float, default=EPISODE_BUDGET_S) + args = parser.parse_args() + + path, summary = render_episode(args.output, args.max_duration) + print(f"GIF: {path} ({path.stat().st_size / 1024:.0f} KiB)") + for key, value in summary.items(): + print(f" {key}: {value}") + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/webots/controllers/atlas_inspection/atlas_inspection.py b/bridge/boston_dynamics/atlas_bridge/webots/controllers/atlas_inspection/atlas_inspection.py new file mode 100644 index 000000000..c6fdb8983 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/webots/controllers/atlas_inspection/atlas_inspection.py @@ -0,0 +1,268 @@ +"""Webots controller for the Atlas shelf-inspection skill. + +Runs inside Webots and drives the robot with the *same* +:class:`~control_core.ShelfInspectionController` and the *same* +URDF kinematics that the MuJoCo and PyBullet backends use. Only the physics +engine and the joint servo differ. + +The result is written as JSON to ``$ATLAS_WEBOTS_RESULT`` so the launcher in +``webots_env.py`` can report it without re-deriving anything. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from pathlib import Path + +import numpy as np + +# The controller runs as its own process, so put the repository on the path. +REPOSITORY_ROOT = Path(__file__).resolve().parents[6] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from bridge.boston_dynamics.atlas_bridge import kinematics # noqa: E402 +from bridge.boston_dynamics.atlas_bridge.control_core import ( # noqa: E402 + POLICY_ID, + ShelfInspectionController, +) +from bridge.boston_dynamics.atlas_bridge.episode import MODEL_SOURCE, REQUIRED_TARGETS # noqa: E402 +from bridge.boston_dynamics.atlas_bridge.model import joint_efforts # noqa: E402 +from bridge.boston_dynamics.atlas_bridge.task import ( # noqa: E402 + END_EFFECTOR_BODY, + EPISODE_BUDGET_S, + FALL_THRESHOLD_M, + SHELF_PARTS, + STANCE_POSE, +) + +from bridge.boston_dynamics.atlas_bridge.task import WEBOTS_SERVO_P # noqa: E402 + +from controller import Supervisor # noqa: E402 (provided by Webots at runtime) + + +class WebotsInspectionEnvironment: + """Thin Webots adapter exposing the same surface as the other backends.""" + + def __init__(self) -> None: + self.robot = Supervisor() + self.timestep = int(self.robot.getBasicTimeStep()) + self.names = tuple(joint_efforts()) + + self.motors = {} + self.sensors = {} + for name in self.names: + motor = self.robot.getDevice(name) + sensor = self.robot.getDevice(f"{name}_sensor") + if motor is None or sensor is None: + raise RuntimeError(f"Webots PROTO is missing device for joint {name}") + sensor.enable(self.timestep) + # Webots' position servo is an implicit velocity-level controller. + # Its default gain of P=10 tracks too slowly to hold a 182 kg + # humanoid: the ankle lags, the torso pitches and Atlas topples after + # about a second. P=120 holds the stance at 0.911 m, matching the + # other two engines. Torque stays clamped to the URDF effort limit + # by the motor itself. + motor.setControlPID(WEBOTS_SERVO_P, 0.0, 0.0) + self.motors[name] = motor + self.sensors[name] = sensor + + self.self_node = self.robot.getSelf() + # DEF nodes that live inside a PROTO are not reachable through the + # world-level getFromDef; they have to be resolved against the PROTO + # instance itself. + self.hand_node = self.self_node.getFromProtoDef(END_EFFECTOR_BODY) + if self.hand_node is None: + self.hand_node = self.robot.getFromDef(END_EFFECTOR_BODY) + if self.hand_node is None: + raise RuntimeError(f"Webots PROTO exposes no DEF {END_EFFECTOR_BODY}") + self.shelf_nodes = [ + self.robot.getFromDef(part["name"]) for part in SHELF_PARTS + ] + missing = [ + part["name"] for part, node in zip(SHELF_PARTS, self.shelf_nodes) if node is None + ] + if missing: + raise RuntimeError(f"World is missing shelf solids: {missing}") + for node in self.shelf_nodes: + node.enableContactPointsTracking(self.timestep) + + self.min_pelvis_height = math.inf + self.max_end_effector_speed = 0.0 + self.shelf_contacts = 0 + self.fall_detected = False + self._previous_hand = None + + # -- episode ----------------------------------------------------------- + def joint_limits(self) -> dict[str, tuple[float, float]]: + limits = {} + for name, motor in self.motors.items(): + low, high = motor.getMinPosition(), motor.getMaxPosition() + limits[name] = (low, high) if low < high else (-math.pi, math.pi) + return limits + + def reset(self, joint_targets: dict[str, float]) -> dict: + pose = {**STANCE_POSE, **joint_targets} + for name, motor in self.motors.items(): + motor.setPosition(float(pose.get(name, 0.0))) + # One warm-up step so the position sensors return real values instead of + # NaN on the first read. + self.robot.step(self.timestep) + self.robot.step(self.timestep) + self.min_pelvis_height = self._pelvis_height() + self.max_end_effector_speed = 0.0 + self.shelf_contacts = 0 + self.fall_detected = False + self._previous_hand = None + return self.observe() + + def step(self, joint_targets: dict[str, float]) -> dict: + for name, motor in self.motors.items(): + motor.setPosition(float(joint_targets.get(name, 0.0))) + self.robot.step(self.timestep) + return self.observe() + + def safe_stop(self) -> dict: + for name, motor in self.motors.items(): + motor.setPosition(float(self.sensors[name].getValue())) + self.robot.step(self.timestep) + return self.observe() + + # -- measurement ------------------------------------------------------- + def _pelvis_height(self) -> float: + return float(self.self_node.getPosition()[2]) + + def end_effector(self) -> np.ndarray: + return np.array(self.hand_node.getPosition(), dtype=np.float64) + + def joint_angles(self) -> dict[str, float]: + return {name: float(sensor.getValue()) for name, sensor in self.sensors.items()} + + def base_rotation(self) -> np.ndarray: + return np.array(self.self_node.getOrientation(), dtype=np.float64).reshape(3, 3) + + def observe(self) -> dict: + height = self._pelvis_height() + self.min_pelvis_height = min(self.min_pelvis_height, height) + if height < FALL_THRESHOLD_M: + self.fall_detected = True + + hand = self.end_effector() + speed = 0.0 + if self._previous_hand is not None: + speed = float(np.linalg.norm(hand - self._previous_hand)) / (self.timestep / 1000.0) + self._previous_hand = hand + self.max_end_effector_speed = max(self.max_end_effector_speed, speed) + + contacts = sum(len(node.getContactPoints()) for node in self.shelf_nodes) + self.shelf_contacts += contacts + + orientation = self.base_rotation() + pitch = math.atan2(-orientation[2, 0], math.hypot(orientation[2, 1], orientation[2, 2])) + roll = math.atan2(orientation[2, 1], orientation[2, 2]) + + return { + "sim_time": self.robot.getTime(), + "pelvis_height": height, + "end_effector": hand, + "end_effector_speed": speed, + "torso_roll": roll, + "torso_pitch": pitch, + "shelf_contacts_step": contacts, + "upright": height >= FALL_THRESHOLD_M, + } + + +def run(max_duration_seconds: float) -> dict: + environment = WebotsInspectionEnvironment() + controller = ShelfInspectionController(budget_seconds=max_duration_seconds) + observation = environment.reset(controller.reset(environment.joint_limits())) + + control_steps = 0 + plan = None + # Measured exactly as episode.py measures it, so the three engines report + # the same quantity: the episode peak lands in RETURN, where the stance + # pose is commanded as a step, so the inspecting speed is reported too. + max_task_phase_speed = 0.0 + previous_hand = None + while observation["sim_time"] < max_duration_seconds: + angles = environment.joint_angles() + jacobian = kinematics.jacobian(angles, base_rotation=environment.base_rotation()) + plan = controller.step( + environment.end_effector(), jacobian, observation["sim_time"], angles + ) + phase = controller.state.phase + observation = environment.step(plan.joint_targets) + hand = environment.end_effector() + if previous_hand is not None and phase in ("REACH", "VERIFY"): + travelled = float(np.linalg.norm(hand - previous_hand)) + max_task_phase_speed = max( + max_task_phase_speed, travelled / (environment.timestep / 1000.0) + ) + previous_hand = hand + control_steps += 1 + if environment.fall_detected or controller.finished: + break + + diagnostics = controller.diagnostics() + completed = diagnostics["targets_completed"] + errors = [entry["final_error_m"] for entry in diagnostics["per_target"] if entry["reached"]] + success = ( + completed == REQUIRED_TARGETS + and not environment.fall_detected + and environment.shelf_contacts == 0 + ) + + return { + "simulator_engine": "Webots", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": MODEL_SOURCE, + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": POLICY_ID, + "status": "success" if success else "failure", + "success": success, + "completion_reason": ( + "fall" if environment.fall_detected + else "sequence_complete" if controller.finished + else "time_limit" + ), + "safe_stop_applied": False, + "sim_duration_seconds": round(float(observation["sim_time"]), 3), + "control_steps": control_steps, + "targets_total": REQUIRED_TARGETS, + "targets_completed": completed, + "mean_position_error_m": round(sum(errors) / len(errors), 5) if errors else None, + "max_position_error_m": round(max(errors), 5) if errors else None, + "final_pelvis_height_m": round(float(observation["pelvis_height"]), 4), + "min_pelvis_height_m": round(float(environment.min_pelvis_height), 4), + "fall_threshold_m": FALL_THRESHOLD_M, + "fall_detected": environment.fall_detected, + "shelf_contacts": environment.shelf_contacts, + "max_end_effector_speed_mps": round(environment.max_end_effector_speed, 4), + "max_end_effector_speed_inspecting_mps": round(max_task_phase_speed, 4), + "final_torso_roll_rad": round(float(observation["torso_roll"]), 4), + "final_torso_pitch_rad": round(float(observation["torso_pitch"]), 4), + "final_phase": plan.phase if plan else "STAND", + "policy_state": diagnostics, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--max-duration", type=float, default=EPISODE_BUDGET_S) + args, _ = parser.parse_known_args() + + result = run(args.max_duration) + destination = os.environ.get("ATLAS_WEBOTS_RESULT") + if destination: + Path(destination).write_text(json.dumps(result, indent=2), encoding="utf-8") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/webots_env.py b/bridge/boston_dynamics/atlas_bridge/webots_env.py new file mode 100644 index 000000000..ea461d671 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/webots_env.py @@ -0,0 +1,266 @@ +"""Webots backend: build the world from the pinned URDF and run it headless. + +Webots needs a PROTO and a ``.wbt`` world rather than a URDF, so both are +*generated* from the same pinned Atlas description and the same +:mod:`task` geometry that MuJoCo and PyBullet use. Neither generated file is +committed — regenerating them is part of setup, exactly like fetching the model. + +If Webots is not installed, :func:`webots_available` returns ``False`` and +:mod:`sim2sim` reports the engine as unavailable. It never silently substitutes +a different robot or a different task. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +from .model import physics_urdf +from .task import EPISODE_BUDGET_S, SHELF_PARTS + +HERE = Path(__file__).resolve().parent +WEBOTS_DIR = HERE / "webots" +PROTO_DIR = WEBOTS_DIR / "protos" +WORLD_DIR = WEBOTS_DIR / "worlds" +CONTROLLER_DIR = WEBOTS_DIR / "controllers" +WORLD_PATH = WORLD_DIR / "atlas_shelf_inspection.wbt" +RESULT_PATH = WEBOTS_DIR / "webots_inspection_result.json" +PROTO_NAME = "Atlas" + +#: Pelvis spawn height, matching the other two engines' settled stance. +SPAWN_HEIGHT_M = 0.9148 +#: How often to check whether the controller has written its result. +RESULT_POLL_SECONDS = 1.0 + + +def find_webots() -> Path | None: + """Locate the Webots executable via ``WEBOTS_EXE``, PATH, or install paths.""" + override = os.environ.get("WEBOTS_EXE") + if override and Path(override).is_file(): + return Path(override) + for executable in ("webots", "webots.exe"): + found = shutil.which(executable) + if found: + return Path(found) + candidates = [ + Path(r"C:\Program Files\Webots\msys64\mingw64\bin\webots.exe"), + Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Webots" + / "msys64" / "mingw64" / "bin" / "webots.exe", + Path("/usr/local/webots/webots"), + Path("/snap/bin/webots"), + ] + return next((item for item in candidates if item.is_file()), None) + + +def webots_available() -> bool: + return find_webots() is not None + + +def _webots_home(executable: Path) -> Path: + for candidate in (executable.parent, *executable.parents): + if (candidate / "resources").is_dir(): + return candidate + return executable.parents[min(3, len(executable.parents) - 1)] + + +def generate_proto() -> Path: + """Convert the pinned Atlas URDF into a Webots PROTO. + + The output directory is cleared first: urdf2webots names the PROTO after the + URDF's ```` and writes it into a subdirectory, so regenerating + on top of a previous run would otherwise leave stale files that the next + lookup could pick up instead. + """ + from urdf2webots.importer import convertUrdfFile + + if PROTO_DIR.exists(): + shutil.rmtree(PROTO_DIR) + PROTO_DIR.mkdir(parents=True) + convertUrdfFile( + input=str(physics_urdf()), + output=str(PROTO_DIR / PROTO_NAME), + initTranslation=f"0 0 {SPAWN_HEIGHT_M}", + linkToDef=True, + ) + generated = sorted(PROTO_DIR.rglob("*.proto")) + if not generated: + raise RuntimeError("urdf2webots produced no PROTO file") + if len(generated) > 1: + raise RuntimeError(f"urdf2webots produced several PROTOs: {generated}") + + source = generated[0] + target = PROTO_DIR / f"{PROTO_NAME}.proto" + if source != target: + target.write_text( + source.read_text(encoding="utf-8").replace(source.stem, PROTO_NAME), + encoding="utf-8", + ) + shutil.rmtree(source.parent) if source.parent != PROTO_DIR else source.unlink() + return target + + +def _shelf_nodes() -> str: + """Render :data:`task.SHELF_PARTS` as Webots Solid nodes.""" + nodes = [] + for part in SHELF_PARTS: + x, y, z = part["pos"] + half_x, half_y, half_z = part["half"] + nodes.append( + f' DEF {part["name"]} Solid {{\n' + f' translation {x} {y} {z}\n' + f' children [\n' + f' Shape {{\n' + f' appearance PBRAppearance {{ baseColor 0.55 0.42 0.28 roughness 1 }}\n' + f' geometry Box {{ size {half_x * 2} {half_y * 2} {half_z * 2} }}\n' + f' }}\n' + f' ]\n' + f' name "{part["name"]}"\n' + f' boundingObject Box {{ size {half_x * 2} {half_y * 2} {half_z * 2} }}\n' + f' }}' + ) + return "\n".join(nodes) + + +def generate_world(max_duration_seconds: float = EPISODE_BUDGET_S) -> Path: + """Write the ``.wbt`` world from the shared task geometry.""" + WORLD_DIR.mkdir(parents=True, exist_ok=True) + # Only base nodes and the generated Atlas PROTO are used, so the world needs + # no EXTERNPROTO fetches from the network and runs offline in CI. + world = f"""#VRML_SIM R2025a utf8 + +EXTERNPROTO "../protos/{PROTO_NAME}.proto" + +WorldInfo {{ + basicTimeStep 2 + gravity 9.81 + contactProperties [ + ContactProperties {{ coulombFriction [ 1.1 ] bounce 0 }} + ] +}} +Viewpoint {{ + orientation -0.32 0.12 0.94 2.2 + position 2.6 -2.1 1.7 +}} +DirectionalLight {{ + direction -0.3 0.3 -1 + intensity 2.6 + castShadows TRUE +}} +DirectionalLight {{ + direction 0.6 0.6 -1 + intensity 1.2 +}} +DEF floor Solid {{ + children [ + Shape {{ + appearance PBRAppearance {{ baseColor 0.35 0.37 0.40 roughness 1 metalness 0 }} + geometry Plane {{ size 20 20 }} + }} + ] + name "floor" + boundingObject Plane {{ size 20 20 }} +}} +{_shelf_nodes()} +{PROTO_NAME} {{ + translation 0 0 {SPAWN_HEIGHT_M} + name "atlas" + controller "atlas_inspection" + controllerArgs [ "--max-duration" "{max_duration_seconds}" ] + supervisor TRUE +}} +""" + WORLD_PATH.write_text(world, encoding="utf-8") + return WORLD_PATH + + +def setup(max_duration_seconds: float = EPISODE_BUDGET_S) -> tuple[Path, Path]: + """Generate everything Webots needs to run the inspection task.""" + return generate_proto(), generate_world(max_duration_seconds) + + +def run_webots_episode( + max_duration_seconds: float = EPISODE_BUDGET_S, timeout_seconds: int = 600 +) -> dict: + """Run the inspection task inside Webots and return the controller's result.""" + executable = find_webots() + if executable is None: + return { + "simulator_engine": "Webots", + "status": "failure", + "success": False, + "error": "Webots was not found. Install Webots R2025a or set WEBOTS_EXE.", + } + + setup(max_duration_seconds) + RESULT_PATH.unlink(missing_ok=True) + + environment = dict(os.environ) + environment["WEBOTS_CONTROLLER_PATH"] = str(CONTROLLER_DIR) + environment.setdefault("WEBOTS_HOME", str(_webots_home(executable))) + environment["ATLAS_WEBOTS_RESULT"] = str(RESULT_PATH) + command = [ + str(executable), "--batch", "--mode=fast", "--no-rendering", + "--stdout", "--stderr", str(WORLD_PATH), + ] + # On Windows webots.exe is a launcher that outlives the simulation, so wait + # for the controller's result file rather than for the process to exit. + process = subprocess.Popen( + command, cwd=WORLD_DIR, env=environment, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + deadline = time.monotonic() + timeout_seconds + try: + while time.monotonic() < deadline: + if RESULT_PATH.is_file(): + break + if process.poll() is not None and not RESULT_PATH.is_file(): + break + time.sleep(RESULT_POLL_SECONDS) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + + if not RESULT_PATH.is_file(): + output = process.stdout.read() if process.stdout else "" + return { + "simulator_engine": "Webots", + "status": "failure", + "success": False, + "error": "Webots produced no result file.", + "output": output[-2000:], + } + return json.loads(RESULT_PATH.read_text(encoding="utf-8")) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Atlas inspection task in Webots.") + parser.add_argument("--setup-only", action="store_true", help="Generate PROTO and world only.") + parser.add_argument("--max-duration", type=float, default=EPISODE_BUDGET_S) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + if args.setup_only: + proto, world = setup(args.max_duration) + print(f"PROTO: {proto}\nWorld: {world}") + return + + result = run_webots_episode(args.max_duration) + rendered = json.dumps(result, indent=2) + print(rendered) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result.get("success") else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_bridge/x402.py b/bridge/boston_dynamics/atlas_bridge/x402.py new file mode 100644 index 000000000..049c308c1 --- /dev/null +++ b/bridge/boston_dynamics/atlas_bridge/x402.py @@ -0,0 +1,246 @@ +"""x402 payment verification for Fabric RoboPay. + +Implements protocol-level x402 challenge/verification: +- Validates payment receipt structure +- Checks amount, network, asset, expiry, replay protection +- Supports optional facilitator integration for on-chain verification +""" + +from __future__ import annotations + +import re + +import json +import time +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +#: An EVM transaction hash is 32 bytes: "0x" followed by 64 hex digits. A +#: receipt whose hash cannot be one is rejected before anything is executed, +#: so a settlement reference can never be an arbitrary string. +TX_HASH_PATTERN = re.compile(r"^0x[0-9a-fA-F]{64}$") + + +class X402Error(Enum): + MISSING_PAYMENT = "MISSING_PAYMENT" + INVALID_FORMAT = "INVALID_FORMAT" + AMOUNT_MISMATCH = "AMOUNT_MISMATCH" + NETWORK_MISMATCH = "NETWORK_MISMATCH" + ASSET_MISMATCH = "ASSET_MISMATCH" + EXPIRED = "EXPIRED" + MALFORMED_TX_HASH = "MALFORMED_TX_HASH" + FACILITATOR_REJECTED = "FACILITATOR_REJECTED" + REPLAY_DETECTED = "REPLAY_DETECTED" + VERIFICATION_FAILED = "VERIFICATION_FAILED" + + +@dataclass(frozen=True) +class X402Receipt: + amount: str + asset: str + network: str + tx_hash: str + payer: str = "" + payee: str = "" + expiry: float = 0.0 + block_number: int = 0 + + @classmethod + def from_dict(cls, data: dict) -> "X402Receipt": + return cls( + amount=str(data.get("amount", "")), + asset=str(data.get("asset", "")), + network=str(data.get("network", "")), + tx_hash=str(data.get("txHash", data.get("tx_hash", ""))), + payer=str(data.get("payer", "")), + payee=str(data.get("payee", "")), + expiry=float(data.get("expiry", 0)), + block_number=int(data.get("blockNumber", data.get("block_number", 0))), + ) + + +@dataclass +class X402VerificationResult: + valid: bool + error: Optional[X402Error] = None + message: str = "" + receipt: Optional[X402Receipt] = None + + +@dataclass(frozen=True) +class PaymentPolicy: + network: str + asset: str + amount: str + settle_on_failure: bool = False + replay_protection: bool = True + + +class X402Verifier: + """x402 payment verifier. + + Two layers, in this order: + + 1. **Protocol checks** — structure, amount, asset, network, expiry, the + shape of the settlement reference, and replay. These are cheap and they + reject the obvious cases before anything else happens. + 2. **Facilitator verification** — the only step that can tell a real + authorization from a well-formed forgery, because only the facilitator + recovers the signer. Pass a :class:`~.facilitator.FacilitatorClient` to + enable it. + + Without a facilitator the verifier is explicit about what it is: a protocol + check. :attr:`verifies_authorization` says which of the two you have, so a + caller can never mistake one for the other. + """ + + def __init__( + self, + policy: PaymentPolicy, + facilitator=None, + payment_requirements: dict | None = None, + ): + self._policy = policy + self._facilitator = facilitator + self._payment_requirements = payment_requirements + self._seen_hashes: set[str] = set() + + @property + def verifies_authorization(self) -> bool: + """True only when a facilitator actually checks the signature.""" + return self._facilitator is not None + + @property + def policy(self) -> PaymentPolicy: + return self._policy + + def verify( + self, + payment_header: str | dict | None, + action_id: str = "", + ) -> X402VerificationResult: + if payment_header is None: + return X402VerificationResult( + valid=False, + error=X402Error.MISSING_PAYMENT, + message="No payment header provided. Payment required.", + ) + + receipt = self._parse_receipt(payment_header) + if receipt is None: + return X402VerificationResult( + valid=False, + error=X402Error.INVALID_FORMAT, + message="Malformed x402 payment receipt.", + ) + + if receipt.amount != self._policy.amount: + return X402VerificationResult( + valid=False, + error=X402Error.AMOUNT_MISMATCH, + message=f"Expected amount {self._policy.amount}, got {receipt.amount}.", + receipt=receipt, + ) + + if receipt.network != self._policy.network: + return X402VerificationResult( + valid=False, + error=X402Error.NETWORK_MISMATCH, + message=f"Expected network {self._policy.network}, got {receipt.network}.", + receipt=receipt, + ) + + if receipt.asset != self._policy.asset: + return X402VerificationResult( + valid=False, + error=X402Error.ASSET_MISMATCH, + message=f"Expected asset {self._policy.asset}, got {receipt.asset}.", + receipt=receipt, + ) + + if not TX_HASH_PATTERN.match(receipt.tx_hash): + return X402VerificationResult( + valid=False, + error=X402Error.MALFORMED_TX_HASH, + message=( + "Settlement reference is not an EVM transaction hash: " + f"{receipt.tx_hash!r}." + ), + receipt=receipt, + ) + + if receipt.expiry > 0 and time.time() > receipt.expiry: + return X402VerificationResult( + valid=False, + error=X402Error.EXPIRED, + message="Payment receipt has expired.", + receipt=receipt, + ) + + # Only the facilitator can distinguish a real authorization from a + # well-formed forgery, so it runs before replay is recorded and before + # anything is executed. + if self._facilitator is not None: + authorization = self._authorization_payload(payment_header) + verdict = self._facilitator.verify( + authorization, self._payment_requirements or {} + ) + if not verdict.is_valid: + return X402VerificationResult( + valid=False, + error=X402Error.FACILITATOR_REJECTED, + message=verdict.summary, + receipt=receipt, + ) + + if self._policy.replay_protection and receipt.tx_hash: + tx_key = f"{receipt.tx_hash}:{receipt.amount}" + if tx_key in self._seen_hashes: + return X402VerificationResult( + valid=False, + error=X402Error.REPLAY_DETECTED, + message=f"Replay detected for tx {receipt.tx_hash}.", + receipt=receipt, + ) + self._seen_hashes.add(tx_key) + + return X402VerificationResult( + valid=True, + message="Payment verified.", + receipt=receipt, + ) + + def _parse_receipt(self, payment_header: str | dict) -> X402Receipt | None: + try: + if isinstance(payment_header, str): + data = json.loads(payment_header) + elif isinstance(payment_header, dict): + data = payment_header + else: + return None + + if not all(k in data for k in ("amount", "network")): + return None + + if "txHash" not in data and "tx_hash" not in data: + return None + + return X402Receipt.from_dict(data) + except (json.JSONDecodeError, TypeError, ValueError): + return None + + @staticmethod + def _authorization_payload(payment_header) -> dict: + """The x402 payment payload the facilitator expects to verify.""" + if isinstance(payment_header, dict): + nested = payment_header.get("paymentPayload") + if isinstance(nested, dict): + return nested + return payment_header + return {} + + def record_settlement(self, tx_hash: str, amount: str) -> None: + tx_key = f"{tx_hash}:{amount}" + self._seen_hashes.add(tx_key) diff --git a/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py b/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py index 09ee7a9e7..2ce12de68 100644 --- a/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py +++ b/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py @@ -1,4 +1,5 @@ """Parse Fabric tunnel Action Event payloads.""" +import hashlib import json from dataclasses import dataclass, field from typing import Any, Dict, Optional @@ -9,32 +10,103 @@ class ActionEvent: action: str params: Dict[str, Any] = field(default_factory=dict) timestamp: str = "" + action_id: str = "" + robot_id: str = "" + skill_id: str = "" + params_hash: str = "" + idempotency_key: str = "" + payment_payload: Optional[Dict[str, Any]] = None + payment_requirements: Optional[Dict[str, Any]] = None + + +#: The tunnel forwards the caller's request body verbatim (handlers.go), so the +#: identity fields arrive in whatever casing the caller used. Accepting both +#: spellings is what stops a working local demo from silently ignoring a real +#: Fabric request. +_FIELD_ALIASES = { + "action_id": ("action_id", "actionId"), + "robot_id": ("robot_id", "robotId"), + "skill_id": ("skill_id", "skillId"), + "idempotency_key": ("idempotency_key", "idempotencyKey"), + "params_hash": ("params_hash", "paramsHash"), + "params": ("params", "parameters"), +} + + +def _field(payload: Dict[str, Any], name: str, default: Any = "") -> Any: + for key in _FIELD_ALIASES.get(name, (name,)): + if key in payload and payload[key] is not None: + return payload[key] + return default def parse_action_event(raw: bytes) -> Optional[ActionEvent]: """Parse a Fabric Action Event from raw bytes. - Expected schema (tunnel handlers.go:97-104):: + Expected schema (tunnel handlers.go):: { - "payload": {"action": "move_forward", "params": {"speed": 0.5}}, - "transaction_details": {...}, + "payload": { + "action": "inspect_shelf", + "params": {"maxDurationSec": 30}, + "action_id": "act-001", # or "actionId" + "robot_id": "atlas-sim-01", # or "robotId" + "skill_id": "inspect_shelf", # or "skillId" + "idempotency_key": "idem-001" # or "idempotencyKey" + }, + "transaction_details": { + "payment_payload": {...}, + "payment_requirements": {...} + }, "timestamp": "2026-01-01T00:00:00Z" } + ``params_hash`` is recomputed from the parameters rather than trusted from + the wire, so a caller cannot claim one set of parameters and send another. + It is always emitted in the ``sha256:`` form the execution mapping + declares, including for an empty parameter set — a bridge that emitted an + empty string there would not match its own published contract. + + ``skill_id`` is never inferred from ``action``: a caller that omits it has + not said which registered skill it is paying for, and the bridge refuses the + envelope rather than guessing. + Returns None on parse failure. """ try: event = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): return None + if not isinstance(event, dict): + return None payload = event.get("payload") or {} if not isinstance(payload, dict): return None + params = _field(payload, "params", {}) or {} + if not isinstance(params, dict): + return None + # Canonical form, computed the same way for every parameter set including + # the empty one, so the published sha256: contract always holds. + params_bytes = json.dumps(params, sort_keys=True, separators=(",", ":")).encode("utf-8") + + details = event.get("transaction_details") or {} + if not isinstance(details, dict): + details = {} + + action = payload.get("action", "stop") return ActionEvent( - action=payload.get("action", "stop"), - params=payload.get("params") or {}, + action=action, + params=params, timestamp=event.get("timestamp", ""), + action_id=str(_field(payload, "action_id")), + robot_id=str(_field(payload, "robot_id")), + skill_id=str(_field(payload, "skill_id")), + params_hash=f"sha256:{hashlib.sha256(params_bytes).hexdigest()}", + idempotency_key=str(_field(payload, "idempotency_key")), + payment_payload=details.get("payment_payload") or details.get("paymentPayload"), + payment_requirements=( + details.get("payment_requirements") or details.get("paymentRequirements") + ), ) diff --git a/docs/evidence/.gitattributes b/docs/evidence/.gitattributes new file mode 100644 index 000000000..0bfefeead --- /dev/null +++ b/docs/evidence/.gitattributes @@ -0,0 +1,4 @@ +# Evidence artefacts are checksummed in the profile's evidence-manifest.yaml. +# Git must not rewrite their line endings, or the recorded sha256 stops matching +# what a reviewer checks out. +* -text diff --git a/docs/evidence/atlas-paid-action.gif b/docs/evidence/atlas-paid-action.gif new file mode 100644 index 000000000..6e8e6da6e Binary files /dev/null and b/docs/evidence/atlas-paid-action.gif differ diff --git a/docs/evidence/atlas-shelf-inspection.gif b/docs/evidence/atlas-shelf-inspection.gif new file mode 100644 index 000000000..552212a3c Binary files /dev/null and b/docs/evidence/atlas-shelf-inspection.gif differ diff --git a/docs/evidence/demo-e2e-evidence.json b/docs/evidence/demo-e2e-evidence.json new file mode 100644 index 000000000..2c812bca1 --- /dev/null +++ b/docs/evidence/demo-e2e-evidence.json @@ -0,0 +1,106 @@ +{ + "demo": "atlas_e2e_x402_flow", + "policy_id": "atlas-shelf-inspection-dls-v1", + "robot_id": "atlas-sim-01", + "steps": [ + { + "step": 1, + "name": "unpaid_402", + "http_status": 402, + "settlement": "skipped" + }, + { + "step": 2, + "name": "invalid_payment", + "http_status": 400, + "settlement": "skipped" + }, + { + "step": 3, + "name": "protocol_valid_payment_executed", + "http_status": 200, + "settlement": "settlement_eligible", + "targets_completed": 3, + "mean_position_error_m": 0.00954 + }, + { + "step": 4, + "name": "replay_detected", + "http_status": 409, + "settlement": "skipped" + } + ], + "settlement_ledger": { + "entries": [ + { + "action_id": "demo-unpaid-001", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_UNPAID", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180055.7593954, + "reason": "No payment provided. HTTP 402 returned.", + "execution_success": false + }, + { + "action_id": "demo-invalid-001", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_REJECTED", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180055.7594385, + "reason": "Expected amount 1000, got 500.", + "execution_success": false + }, + { + "action_id": "demo-paid-001", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SETTLEMENT_ELIGIBLE", + "receipt_tx_hash": "0x94ad618a792cb57bcfa09eaff1feab4e734c6bd9bcd5c7d70acab3a9461923fb", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "1000", + "asset": "USDC", + "network": "eip155:84532", + "block_number": 0, + "timestamp": 1787180055.759475, + "reason": "Execution succeeded and settlement is authorised by policy. No on-chain transaction was made in this run.", + "execution_success": true + }, + { + "action_id": "demo-replay-001", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_REJECTED", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180063.9514058, + "reason": "Replay detected for tx 0x94ad618a792cb57bcfa09eaff1feab4e734c6bd9bcd5c7d70acab3a9461923fb.", + "execution_success": false + } + ], + "total": 4, + "settled_on_chain": 0, + "settlement_eligible_not_on_chain": 1, + "skipped_failure": 0, + "skipped_unpaid": 1 + } +} diff --git a/docs/evidence/fabric-relay-e2e.json b/docs/evidence/fabric-relay-e2e.json new file mode 100644 index 000000000..5f1f8a4ef --- /dev/null +++ b/docs/evidence/fabric-relay-e2e.json @@ -0,0 +1,302 @@ +{ + "evidence": "atlas_fabric_relay_end_to_end", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "relay": "https://api.fabric.foundation/api/core", + "relay_transport": "wss://api.fabric.foundation/api/core/ws/robot", + "stood_in_for": "nothing \u2014 the relay, the tunnel, Zenoh, the simulator and the facilitator are all the real components", + "robot_id": "atlas-sim-1787197727", + "skill_id": "inspect_shelf", + "action_id": "atlas-inspect-1787197727", + "idempotency_key": "atlas-inspect-1787197727", + "payer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "payee": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "dry_run": false, + "discovery": { + "http_status": 200, + "robot_id": "atlas-sim-1787197727", + "robot_discovered": true, + "skills": [ + { + "skill_id": "inspect_shelf", + "description": "Drive free-standing Atlas through a three-point shelf inspection. A state machine sequences STAND, REACH and VERIFY per target while a damped least-squares resolved-rate loop closes on the measured end-effector pose. Succeeds only when every target is reached and held, the robot is still standing, and nothing touched the shelf.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 60, + "default": 30 + } + } + }, + { + "skill_id": "stop", + "description": "Interrupt an active episode, release every actuator and freeze the robot. An interrupted inspection returns a correlated safe_stopped failure, so that action can never settle. Priced like any other skill because it arrives over the same paid-action channel; safety does not depend on it, since the episode is bounded by maxDurationSec and the bridge stops on its own when the budget expires.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } + ], + "skill_ids": [ + "inspect_shelf", + "stop" + ], + "discovered_skill": "inspect_shelf", + "discovered_price_usdc": "0.001" + }, + "steps": [ + { + "step": "discovery", + "http_status": 200, + "robot_id": "atlas-sim-1787197727", + "robot_discovered": true, + "skills": [ + { + "skill_id": "inspect_shelf", + "description": "Drive free-standing Atlas through a three-point shelf inspection. A state machine sequences STAND, REACH and VERIFY per target while a damped least-squares resolved-rate loop closes on the measured end-effector pose. Succeeds only when every target is reached and held, the robot is still standing, and nothing touched the shelf.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 60, + "default": 30 + } + } + }, + { + "skill_id": "stop", + "description": "Interrupt an active episode, release every actuator and freeze the robot. An interrupted inspection returns a correlated safe_stopped failure, so that action can never settle. Priced like any other skill because it arrives over the same paid-action channel; safety does not depend on it, since the episode is bounded by maxDurationSec and the bridge stops on its own when the budget expires.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } + ], + "skill_ids": [ + "inspect_shelf", + "stop" + ], + "discovered_skill": "inspect_shelf", + "discovered_price_usdc": "0.001" + }, + { + "step": "unpaid_action", + "http_status": 402, + "payment_required_header": true, + "requirements": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "x402_version": 2, + "body": { + "body": null + }, + "refused_by": "hosted Fabric relay + tunnel x402 middleware" + }, + { + "step": "paid_action", + "http_status": 202, + "accepted": true, + "immediate": true, + "action_id_echoed": "atlas-inspect-1787197727", + "status_url": "/action/atlas-inspect-1787197727/status", + "payment_response": {}, + "settlement_ordering": "settled by the tunnel after the result, only on success", + "body": { + "action_id": "atlas-inspect-1787197727", + "robot_id": "atlas-sim-1787197727", + "status": "accepted", + "status_url": "/action/atlas-inspect-1787197727/status", + "timestamp": "2026-08-20T06:48:52+03:00" + } + }, + { + "step": "terminal_status", + "state": "succeeded", + "action_id": "atlas-inspect-1787197727", + "correlated": true, + "params_hash": "sha256:61f3d32028d32e07bf1b50cde4732d0b6cc851e53ebee527f7a1408b1428e17c", + "idempotency_key": "atlas-inspect-1787197727", + "targets_completed": 3, + "targets_total": 3, + "settled": true, + "settlement": { + "transaction": "0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940", + "network": "eip155:84532", + "payer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc" + }, + "settlement_error": null, + "read_from": "hosted Fabric relay" + }, + { + "step": "settlement", + "tx_hash": "0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940", + "confirmed": true, + "block_number": 45714728, + "submitted_by": "0xd407e409e34e0b9afb99ecceb609bdbcd5e7f1bf", + "explorer": "https://sepolia.basescan.org/tx/0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940", + "transfer": { + "token_contract": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "from": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "to": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "raw_amount": 1000, + "amount_usdc": 0.001 + }, + "authorization_nonce": "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d", + "expected_nonce_from_action_id": "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d", + "nonce_binds_settlement_to_action": true, + "asset_is_declared_usdc": true + }, + { + "step": "authorization_state", + "authorizer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "nonce": "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d", + "nonce_derivation": "keccak256(action_id)", + "contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "method": "authorizationState(address,bytes32)", + "queried_at_block": "0x2b98d28", + "used": true, + "queried": true + } + ], + "terminal_status": { + "action_id": "atlas-inspect-1787197727", + "robot_id": "atlas-sim-1787197727", + "skill_id": "inspect_shelf", + "state": "succeeded", + "params_hash": "sha256:61f3d32028d32e07bf1b50cde4732d0b6cc851e53ebee527f7a1408b1428e17c", + "idempotency_key": "atlas-inspect-1787197727", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "result": { + "simulator_engine": "MuJoCo", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.606, + "wall_time_seconds": 8.361, + "control_steps": 2303, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00954, + "max_position_error_m": 0.01349, + "final_pelvis_height_m": 0.9084, + "min_pelvis_height_m": 0.9084, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 1.1339, + "max_end_effector_speed_inspecting_mps": 0.3355, + "final_torso_roll_rad": 0.0343, + "final_torso_pitch_rad": 0.0971, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.0051, + "best_error_m": 0.0051, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01004, + "best_error_m": 0.01004, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.01349, + "best_error_m": 0.01347, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } + }, + "settled": true, + "settlement": { + "transaction": "0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940", + "network": "eip155:84532", + "payer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc" + }, + "updated_at": "2026-08-20T03:49:01Z" + }, + "on_chain": { + "confirmed": true, + "block_number": 45714728, + "submitted_by": "0xd407e409e34e0b9afb99ecceb609bdbcd5e7f1bf", + "explorer": "https://sepolia.basescan.org/tx/0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940", + "transfer": { + "token_contract": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "from": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "to": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "raw_amount": 1000, + "amount_usdc": 0.001 + }, + "authorization_nonce": "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d", + "expected_nonce_from_action_id": "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d", + "nonce_binds_settlement_to_action": true, + "asset_is_declared_usdc": true + }, + "payment_safety": { + "settlement_ordering": "POST /action answers 202 immediately; the tunnel settles from a background watcher and only when the correlated result reports success", + "execution_failed": false, + "settled": true, + "settled_despite_failure": false + }, + "invariants": { + "the relay reported the robot connected": true, + "skill discovery returned the inspection skill": true, + "the price was discovered, not assumed": true, + "the relay quoted the discovered price": true, + "the relay refused an unpaid action with 402": true, + "the relay advertised payment requirements": true, + "the paid action was accepted": true, + "the relay reported the action succeeded": true, + "every inspection target was reached": true, + "the terminal status is correlated by action_id": true, + "the relay answered 202 immediately, before the robot finished": true, + "the tunnel settled only after the result": true, + "the settlement is confirmed on Base Sepolia": true, + "the on-chain nonce is keccak256(action_id)": true, + "the token contract records the authorization as spent": true + }, + "all_invariants_hold": true +} diff --git a/docs/evidence/fabric-relay-failure.json b/docs/evidence/fabric-relay-failure.json new file mode 100644 index 000000000..c9d31b1f7 --- /dev/null +++ b/docs/evidence/fabric-relay-failure.json @@ -0,0 +1,191 @@ +{ + "evidence": "atlas_fabric_relay_end_to_end", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "relay": "https://api.fabric.foundation/api/core", + "relay_transport": "wss://api.fabric.foundation/api/core/ws/robot", + "stood_in_for": "nothing \u2014 the relay, the tunnel, Zenoh, the simulator and the facilitator are all the real components", + "robot_id": "atlas-sim-1787197752", + "skill_id": "inspect_shelf", + "action_id": "atlas-inspect-1787197752", + "idempotency_key": "atlas-inspect-1787197752", + "payer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "payee": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "dry_run": false, + "discovery": { + "http_status": 200, + "robot_id": "atlas-sim-1787197752", + "robot_discovered": true, + "skills": [ + { + "skill_id": "inspect_shelf", + "description": "Drive free-standing Atlas through a three-point shelf inspection. A state machine sequences STAND, REACH and VERIFY per target while a damped least-squares resolved-rate loop closes on the measured end-effector pose. Succeeds only when every target is reached and held, the robot is still standing, and nothing touched the shelf.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 60, + "default": 30 + } + } + }, + { + "skill_id": "stop", + "description": "Interrupt an active episode, release every actuator and freeze the robot. An interrupted inspection returns a correlated safe_stopped failure, so that action can never settle. Priced like any other skill because it arrives over the same paid-action channel; safety does not depend on it, since the episode is bounded by maxDurationSec and the bridge stops on its own when the budget expires.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } + ], + "skill_ids": [ + "inspect_shelf", + "stop" + ], + "discovered_skill": "inspect_shelf", + "discovered_price_usdc": "0.001" + }, + "steps": [ + { + "step": "discovery", + "http_status": 200, + "robot_id": "atlas-sim-1787197752", + "robot_discovered": true, + "skills": [ + { + "skill_id": "inspect_shelf", + "description": "Drive free-standing Atlas through a three-point shelf inspection. A state machine sequences STAND, REACH and VERIFY per target while a damped least-squares resolved-rate loop closes on the measured end-effector pose. Succeeds only when every target is reached and held, the robot is still standing, and nothing touched the shelf.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 60, + "default": 30 + } + } + }, + { + "skill_id": "stop", + "description": "Interrupt an active episode, release every actuator and freeze the robot. An interrupted inspection returns a correlated safe_stopped failure, so that action can never settle. Priced like any other skill because it arrives over the same paid-action channel; safety does not depend on it, since the episode is bounded by maxDurationSec and the bridge stops on its own when the budget expires.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } + ], + "skill_ids": [ + "inspect_shelf", + "stop" + ], + "discovered_skill": "inspect_shelf", + "discovered_price_usdc": "0.001" + }, + { + "step": "unpaid_action", + "http_status": 402, + "payment_required_header": true, + "requirements": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "x402_version": 2, + "body": { + "body": null + }, + "refused_by": "hosted Fabric relay + tunnel x402 middleware" + }, + { + "step": "paid_action", + "http_status": 202, + "accepted": true, + "immediate": true, + "action_id_echoed": "atlas-inspect-1787197752", + "status_url": "/action/atlas-inspect-1787197752/status", + "payment_response": {}, + "settlement_ordering": "settled by the tunnel after the result, only on success", + "body": { + "action_id": "atlas-inspect-1787197752", + "robot_id": "atlas-sim-1787197752", + "status": "accepted", + "status_url": "/action/atlas-inspect-1787197752/status", + "timestamp": "2026-08-20T06:49:15+03:00" + } + }, + { + "step": "terminal_status", + "state": "failed", + "action_id": "atlas-inspect-1787197752", + "correlated": true, + "params_hash": "sha256:908150ab67973c3926a2d6c2b09d58fc48e90a709297bb91dae3c1769ea70825", + "idempotency_key": "atlas-inspect-1787197752", + "targets_completed": null, + "targets_total": null, + "settled": false, + "settlement": null, + "settlement_error": null, + "read_from": "hosted Fabric relay" + }, + { + "step": "authorization_state", + "authorizer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "nonce": "0x83d9161bb7f5a4c5a8d5e88a5bc403c1cf3f1c962513354f852b5b9719121860", + "nonce_derivation": "keccak256(action_id)", + "contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "method": "authorizationState(address,bytes32)", + "queried_at_block": "latest", + "used": false, + "queried": true + } + ], + "terminal_status": { + "action_id": "atlas-inspect-1787197752", + "robot_id": "atlas-sim-1787197752", + "skill_id": "inspect_shelf", + "state": "failed", + "params_hash": "sha256:908150ab67973c3926a2d6c2b09d58fc48e90a709297bb91dae3c1769ea70825", + "idempotency_key": "atlas-inspect-1787197752", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "result": { + "error_code": "INVALID_DURATION", + "message": "maxDurationSec must be between 5 and 60.", + "success": false + }, + "settled": false, + "updated_at": "2026-08-20T03:49:15Z" + }, + "on_chain": null, + "payment_safety": { + "settlement_ordering": "POST /action answers 202 immediately; the tunnel settles from a background watcher and only when the correlated result reports success", + "execution_failed": true, + "settled": false, + "settled_despite_failure": false + }, + "invariants": { + "the relay reported the robot connected": true, + "skill discovery returned the inspection skill": true, + "the price was discovered, not assumed": true, + "the relay quoted the discovered price": true, + "the relay refused an unpaid action with 402": true, + "the relay advertised payment requirements": true, + "the action was accepted immediately, as the contract says": true, + "the tunnel did not settle a failed action": true, + "no settlement transaction exists": true, + "nothing was transferred on chain": true, + "the token contract has no record of the authorization being spent": true, + "the status endpoint reported the action failed": true, + "the status carries the real reason, not a generic one": true, + "the failed action is still correlated by action_id": true + }, + "all_invariants_hold": true +} diff --git a/docs/evidence/go-tunnel-e2e-evidence.json b/docs/evidence/go-tunnel-e2e-evidence.json new file mode 100644 index 000000000..4627c03ec --- /dev/null +++ b/docs/evidence/go-tunnel-e2e-evidence.json @@ -0,0 +1,36 @@ +{ + "demo": "atlas_go_tunnel_e2e", + "tunnel": "repository Go tunnel binary (x402 gin middleware + facilitator client)", + "proxy": "minimal stand-in for the hosted Fabric backend", + "action_topic": "robot/tunnel/action", + "result_topic": "robot/tunnel/result", + "robot_id": "atlas-sim-01", + "steps": [ + { + "step": "unpaid", + "action_id": "act-unpaid-4821c848", + "http_status": 402, + "payment_required_header": true, + "executed": false, + "decided_by": "go tunnel x402 middleware" + }, + { + "step": "forged-payment", + "action_id": "act-forged-d3db25ea", + "http_status": 400, + "response": { + "error": "Invalid payment" + }, + "executed": false, + "decided_by": "go tunnel x402 middleware + live facilitator" + } + ], + "simulator_actions_executed": 0, + "invariants": { + "the tunnel refused an unpaid action with HTTP 402": true, + "the tunnel advertised payment requirements": true, + "the tunnel refused a forged payment": true, + "no unpaid or forged action ever reached the simulator": true + }, + "all_invariants_hold": true +} diff --git a/docs/evidence/go-tunnel-e2e-terminal.txt b/docs/evidence/go-tunnel-e2e-terminal.txt new file mode 100644 index 000000000..f6b27c31f --- /dev/null +++ b/docs/evidence/go-tunnel-e2e-terminal.txt @@ -0,0 +1,21 @@ +====================================================================== + Atlas paid action through the real Go tunnel +====================================================================== + proxy listening on ws://127.0.0.1:8791/api/core/ws/robot + bridge listening on robot/tunnel/action as atlas-sim-01 + Go tunnel connected to the proxy + + [unpaid] HTTP 402 + payment-required : True + [forged payment] HTTP 400 + tunnel said : {"error": "Invalid payment"} + +====================================================================== + INVARIANTS +====================================================================== + [OK] the tunnel refused an unpaid action with HTTP 402 + [OK] the tunnel advertised payment requirements + [OK] the tunnel refused a forged payment + [OK] no unpaid or forged action ever reached the simulator + + evidence written to docs\evidence\go-tunnel-e2e-evidence.json diff --git a/docs/evidence/mujoco-inspection-episode.json b/docs/evidence/mujoco-inspection-episode.json new file mode 100644 index 000000000..75dd8c3e4 --- /dev/null +++ b/docs/evidence/mujoco-inspection-episode.json @@ -0,0 +1,71 @@ +{ + "simulator_engine": "MuJoCo", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.606, + "wall_time_seconds": 8.77, + "control_steps": 2303, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00954, + "max_position_error_m": 0.01349, + "final_pelvis_height_m": 0.9084, + "min_pelvis_height_m": 0.9084, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 1.1339, + "max_end_effector_speed_inspecting_mps": 0.3355, + "final_torso_roll_rad": 0.0343, + "final_torso_pitch_rad": 0.0971, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.0051, + "best_error_m": 0.0051, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01004, + "best_error_m": 0.01004, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.01349, + "best_error_m": 0.01347, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } +} diff --git a/docs/evidence/onchain-settlement.json b/docs/evidence/onchain-settlement.json new file mode 100644 index 000000000..5960c9126 --- /dev/null +++ b/docs/evidence/onchain-settlement.json @@ -0,0 +1,68 @@ +{ + "evidence": "on_chain_settlement", + "network": { + "name": "Base Sepolia", + "chain_id": 84532, + "caip2": "eip155:84532" + }, + "asset": { + "symbol": "USDC", + "contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "decimals": 6, + "explorer": "https://sepolia.basescan.org/token/0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }, + "settlement_transaction": { + "hash": "0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39", + "action_id": "act-paid-de66513f791b", + "expected": { + "amount_raw": 1000, + "asset": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "payer": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "payee": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "network": "eip155:84532" + }, + "mismatches": [], + "matches_profile": true, + "succeeded": true, + "authorization_nonce": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de", + "expected_nonce_from_action_id": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de", + "nonce_binds_settlement_to_action": true, + "block_number": 45706216, + "gas_used": 85696, + "contract": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "explorer": "https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39", + "transfer": { + "event": "Transfer(address,address,uint256)", + "token": "USDC", + "token_contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "from": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "to": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "raw_amount": 1000, + "decimals": 6, + "amount": 0.001 + } + }, + "funding_transaction": { + "hash": "0xb37252fda0bc30de9ce98bd1b306c131eda11a4b3fabd9ae11d487d8773fdbbb", + "succeeded": true, + "block_number": 45669921, + "explorer": "https://sepolia.basescan.org/tx/0xb37252fda0bc30de9ce98bd1b306c131eda11a4b3fabd9ae11d487d8773fdbbb", + "description": "Coinbase Developer Platform faucet request that funded the payer wallet with testnet USDC before the settlement above." + }, + "wallets": { + "payer": { + "address": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "explorer": "https://sepolia.basescan.org/address/0xa0597a74f3c3f33797007495bc3dc676f10fc2dc" + }, + "payee": { + "address": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "explorer": "https://sepolia.basescan.org/address/0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8" + } + }, + "notes": [ + "Testnet only. Base Sepolia USDC has no monetary value.", + "This artefact deliberately records no balances: balances change after the fact, while the transaction and its Transfer event do not.", + "The payer wallet is a disposable test wallet and is treated as compromised; no key material is stored in this repository." + ], + "reproduce": "python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence" +} diff --git a/docs/evidence/pybullet-inspection-episode.json b/docs/evidence/pybullet-inspection-episode.json new file mode 100644 index 000000000..94023f995 --- /dev/null +++ b/docs/evidence/pybullet-inspection-episode.json @@ -0,0 +1,71 @@ +{ + "simulator_engine": "PyBullet", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.978, + "wall_time_seconds": 3.105, + "control_steps": 2489, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.01218, + "max_position_error_m": 0.01979, + "final_pelvis_height_m": 0.9398, + "min_pelvis_height_m": 0.9395, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 3.1092, + "max_end_effector_speed_inspecting_mps": 0.3663, + "final_torso_roll_rad": 0.0001, + "final_torso_pitch_rad": 0.0011, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.01005, + "best_error_m": 0.01005, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01979, + "best_error_m": 0.01979, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00671, + "best_error_m": 0.00671, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } +} diff --git a/docs/evidence/reach-envelope.json b/docs/evidence/reach-envelope.json new file mode 100644 index 000000000..3954363cf --- /dev/null +++ b/docs/evidence/reach-envelope.json @@ -0,0 +1,563 @@ +{ + "validation_type": "reach_envelope", + "robot_model": "Boston Dynamics Atlas v4", + "base": "free-standing (no weld, no external support)", + "tolerance_m": 0.03, + "fall_threshold_m": 0.7, + "probe_budget_s": 12.0, + "probes_total": 36, + "probes_usable": 23, + "conservative_core": { + "cells": 15, + "forward_range_m": [ + 0.06, + 0.18 + ], + "vertical_range_m": [ + -0.12, + 0.2 + ] + }, + "probes": [ + { + "offset_forward_m": 0.06, + "offset_vertical_m": 0.2, + "goal": [ + 0.4534, + -0.5631, + 1.1654 + ], + "reached": true, + "final_error_m": 0.01454, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": 0.2, + "goal": [ + 0.5134, + -0.5631, + 1.1654 + ], + "reached": true, + "final_error_m": 0.01395, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 5, + "usable": true + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": 0.2, + "goal": [ + 0.5734, + -0.5631, + 1.1654 + ], + "reached": true, + "final_error_m": 0.02296, + "min_pelvis_height_m": 0.9095, + "fall_detected": false, + "shelf_contacts": 171, + "usable": true + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": 0.2, + "goal": [ + 0.6034, + -0.5631, + 1.1654 + ], + "reached": true, + "final_error_m": 0.02531, + "min_pelvis_height_m": 0.9068, + "fall_detected": false, + "shelf_contacts": 354, + "usable": true + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": 0.2, + "goal": [ + 0.6334, + -0.5631, + 1.1654 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6992, + "fall_detected": true, + "shelf_contacts": 1201, + "usable": false + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": 0.2, + "goal": [ + 0.6934, + -0.5631, + 1.1654 + ], + "reached": false, + "final_error_m": 0.16783, + "min_pelvis_height_m": 0.8712, + "fall_detected": false, + "shelf_contacts": 2951, + "usable": false + }, + { + "offset_forward_m": 0.06, + "offset_vertical_m": 0.1, + "goal": [ + 0.4534, + -0.5631, + 1.0654 + ], + "reached": true, + "final_error_m": 0.01507, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": 0.1, + "goal": [ + 0.5134, + -0.5631, + 1.0654 + ], + "reached": true, + "final_error_m": 0.01527, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": 0.1, + "goal": [ + 0.5734, + -0.5631, + 1.0654 + ], + "reached": true, + "final_error_m": 0.01216, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": 0.1, + "goal": [ + 0.6034, + -0.5631, + 1.0654 + ], + "reached": false, + "final_error_m": 0.04505, + "min_pelvis_height_m": 0.8945, + "fall_detected": false, + "shelf_contacts": 832, + "usable": false + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": 0.1, + "goal": [ + 0.6334, + -0.5631, + 1.0654 + ], + "reached": false, + "final_error_m": 0.12941, + "min_pelvis_height_m": 0.6998, + "fall_detected": true, + "shelf_contacts": 2030, + "usable": false + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": 0.1, + "goal": [ + 0.6934, + -0.5631, + 1.0654 + ], + "reached": false, + "final_error_m": 0.22142, + "min_pelvis_height_m": 0.6991, + "fall_detected": true, + "shelf_contacts": 2762, + "usable": false + }, + { + "offset_forward_m": 0.06, + "offset_vertical_m": 0.0, + "goal": [ + 0.4534, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.02086, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": 0.0, + "goal": [ + 0.5134, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.016, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": 0.0, + "goal": [ + 0.5734, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.01257, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": 0.0, + "goal": [ + 0.6034, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.01142, + "min_pelvis_height_m": 0.907, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": 0.0, + "goal": [ + 0.6334, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.01101, + "min_pelvis_height_m": 0.9, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": 0.0, + "goal": [ + 0.6934, + -0.5631, + 0.9654 + ], + "reached": true, + "final_error_m": 0.01197, + "min_pelvis_height_m": 0.8516, + "fall_detected": false, + "shelf_contacts": 11, + "usable": true + }, + { + "offset_forward_m": 0.06, + "offset_vertical_m": -0.06, + "goal": [ + 0.4534, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.01974, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": -0.06, + "goal": [ + 0.5134, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.01555, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": -0.06, + "goal": [ + 0.5734, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.01381, + "min_pelvis_height_m": 0.9057, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": -0.06, + "goal": [ + 0.6034, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.01362, + "min_pelvis_height_m": 0.8965, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": -0.06, + "goal": [ + 0.6334, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.01321, + "min_pelvis_height_m": 0.872, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": -0.06, + "goal": [ + 0.6934, + -0.5631, + 0.9054 + ], + "reached": true, + "final_error_m": 0.02674, + "min_pelvis_height_m": 0.7812, + "fall_detected": false, + "shelf_contacts": 129, + "usable": true + }, + { + "offset_forward_m": 0.06, + "offset_vertical_m": -0.12, + "goal": [ + 0.4534, + -0.5631, + 0.8454 + ], + "reached": true, + "final_error_m": 0.01887, + "min_pelvis_height_m": 0.91, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": -0.12, + "goal": [ + 0.5134, + -0.5631, + 0.8454 + ], + "reached": true, + "final_error_m": 0.01769, + "min_pelvis_height_m": 0.9059, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": -0.12, + "goal": [ + 0.5734, + -0.5631, + 0.8454 + ], + "reached": true, + "final_error_m": 0.01769, + "min_pelvis_height_m": 0.8598, + "fall_detected": false, + "shelf_contacts": 0, + "usable": true + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": -0.12, + "goal": [ + 0.6034, + -0.5631, + 0.8454 + ], + "reached": true, + "final_error_m": 0.01946, + "min_pelvis_height_m": 0.8044, + "fall_detected": false, + "shelf_contacts": 100, + "usable": true + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": -0.12, + "goal": [ + 0.6334, + -0.5631, + 0.8454 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6986, + "fall_detected": true, + "shelf_contacts": 337, + "usable": false + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": -0.12, + "goal": [ + 0.6934, + -0.5631, + 0.8454 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6995, + "fall_detected": true, + "shelf_contacts": 481, + "usable": false + }, + { + "offset_forward_m": 0.06, + "offset_vertical_m": -0.2, + "goal": [ + 0.4534, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": 0.08405, + "min_pelvis_height_m": 0.6987, + "fall_detected": true, + "shelf_contacts": 110, + "usable": false + }, + { + "offset_forward_m": 0.12, + "offset_vertical_m": -0.2, + "goal": [ + 0.5134, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": 0.23779, + "min_pelvis_height_m": 0.6994, + "fall_detected": true, + "shelf_contacts": 174, + "usable": false + }, + { + "offset_forward_m": 0.18, + "offset_vertical_m": -0.2, + "goal": [ + 0.5734, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6988, + "fall_detected": true, + "shelf_contacts": 269, + "usable": false + }, + { + "offset_forward_m": 0.21, + "offset_vertical_m": -0.2, + "goal": [ + 0.6034, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6985, + "fall_detected": true, + "shelf_contacts": 375, + "usable": false + }, + { + "offset_forward_m": 0.24, + "offset_vertical_m": -0.2, + "goal": [ + 0.6334, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6984, + "fall_detected": true, + "shelf_contacts": 369, + "usable": false + }, + { + "offset_forward_m": 0.3, + "offset_vertical_m": -0.2, + "goal": [ + 0.6934, + -0.5631, + 0.7654 + ], + "reached": false, + "final_error_m": null, + "min_pelvis_height_m": 0.6994, + "fall_detected": true, + "shelf_contacts": 444, + "usable": false + } + ] +} diff --git a/docs/evidence/real-paid-run.json b/docs/evidence/real-paid-run.json new file mode 100644 index 000000000..245ae123d --- /dev/null +++ b/docs/evidence/real-paid-run.json @@ -0,0 +1,183 @@ +{ + "evidence": "real_paid_action_end_to_end", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "action_id": "act-paid-de66513f791b", + "idempotency_key": "idem-act-paid-de66513f791b", + "payment": { + "scheme": "exact", + "protocol": "x402 + EIP-3009 transferWithAuthorization", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount_raw": "1000", + "amount_usdc": "0.001", + "payer": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "payee": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "authorization": { + "from": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "to": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "value": "1000", + "validAfter": "0", + "validBefore": "1787182506", + "nonce": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de" + }, + "nonce_derivation": "keccak256(action_id)", + "expected_nonce": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de", + "facilitator": "https://x402.org/facilitator", + "requirements": { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "1000", + "resource": "https://robopay.invalid/atlas/inspect_shelf?action_id=act-paid-de66513f791b", + "description": "Boston Dynamics Atlas shelf inspection", + "mimeType": "application/json", + "payTo": "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8", + "maxTimeoutSeconds": 60, + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": { + "name": "USDC", + "version": "2" + } + } + }, + "steps": [ + { + "step": "facilitator_verify", + "http_status": 200, + "is_valid": true, + "reason": "", + "payer_recovered_by_facilitator": "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc", + "decided_by": "live x402 facilitator" + }, + { + "step": "robot_execution", + "action_id_echoed": "act-paid-de66513f791b", + "correlated": true, + "status": "success", + "targets_completed": 3, + "targets_total": 3, + "success": true, + "transport": "Zenoh (peer mode)" + }, + { + "step": "facilitator_settle", + "http_status": 200, + "success": true, + "tx_hash": "0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39", + "error": "", + "decided_by": "live x402 facilitator" + } + ], + "execution_result": { + "action_id": "act-paid-de66513f791b", + "robot_id": "atlas-sim-01", + "skill_id": "inspect_shelf", + "params_hash": "sha256:2dd39a34d5a95b171816080d6b615a61c371255e418a130078ab4854f8700fb9", + "idempotency_key": "idem-act-paid-de66513f791b", + "status": "success", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1", + "result": { + "simulator_engine": "MuJoCo", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.606, + "wall_time_seconds": 8.918, + "control_steps": 2303, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00954, + "max_position_error_m": 0.01349, + "final_pelvis_height_m": 0.9084, + "min_pelvis_height_m": 0.9084, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 1.1339, + "max_end_effector_speed_inspecting_mps": 0.3355, + "final_torso_roll_rad": 0.0343, + "final_torso_pitch_rad": 0.0971, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.0051, + "best_error_m": 0.0051, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01004, + "best_error_m": 0.01004, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.01349, + "best_error_m": 0.01347, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } + } + }, + "on_chain": { + "confirmed": true, + "block_number": 45706216, + "gas_used": 85696, + "explorer": "https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39", + "transfer": { + "token_contract": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "from": "0xa0597a74f3c3f33797007495bc3dc676f10fc2dc", + "to": "0x7b9163254a21b249a0d3e34300fc81bb0a43c3e8", + "raw_amount": 1000, + "amount_usdc": 0.001, + "asset": "USDC" + }, + "authorization_nonce": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de", + "expected_nonce_from_action_id": "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de", + "nonce_binds_settlement_to_action": true, + "asset_is_declared_usdc": true, + "amount_matches_declared_price": true + }, + "invariants": { + "the live facilitator verified the authorization": true, + "the robot executed only after that verification": true, + "every inspection target was reached": true, + "the result came back correlated by action_id": true, + "the facilitator settled a real transaction": true, + "the settlement is confirmed on Base Sepolia": true, + "the settled amount is the declared skill price": true, + "the asset is the USDC contract the profile declares": true, + "the on-chain authorization nonce is keccak256(action_id)": true + }, + "all_invariants_hold": true +} diff --git a/docs/evidence/sim2sim-validation.json b/docs/evidence/sim2sim-validation.json new file mode 100644 index 000000000..33b67316e --- /dev/null +++ b/docs/evidence/sim2sim-validation.json @@ -0,0 +1,343 @@ +{ + "validation_type": "sim2sim", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "policy_id": "atlas-shelf-inspection-dls-v1", + "engines": [ + "MuJoCo", + "PyBullet", + "Webots" + ], + "webots": "ran", + "runs": [ + { + "engine": "MuJoCo", + "status": "success", + "targets_completed": 3, + "targets_total": 3, + "mean_position_error_m": 0.00954, + "max_position_error_m": 0.01349, + "min_pelvis_height_m": 0.9084, + "fall_detected": false, + "shelf_contacts": 0, + "sim_duration_seconds": 4.606, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.0051, + "best_error_m": 0.0051, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01004, + "best_error_m": 0.01004, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.01349, + "best_error_m": 0.01347, + "control_steps": 250 + } + ] + }, + { + "engine": "PyBullet", + "status": "success", + "targets_completed": 3, + "targets_total": 3, + "mean_position_error_m": 0.01218, + "max_position_error_m": 0.01979, + "min_pelvis_height_m": 0.9395, + "fall_detected": false, + "shelf_contacts": 0, + "sim_duration_seconds": 4.978, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.01005, + "best_error_m": 0.01005, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01979, + "best_error_m": 0.01979, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00671, + "best_error_m": 0.00671, + "control_steps": 250 + } + ] + }, + { + "engine": "Webots", + "status": "success", + "targets_completed": 3, + "targets_total": 3, + "mean_position_error_m": 0.00898, + "max_position_error_m": 0.01233, + "min_pelvis_height_m": 0.8982, + "fall_detected": false, + "shelf_contacts": 0, + "sim_duration_seconds": 7.704, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.00582, + "best_error_m": 0.00269, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01233, + "best_error_m": 0.01233, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00879, + "best_error_m": 0.00879, + "control_steps": 250 + } + ] + } + ], + "consistency": { + "all_engines_completed_all_targets": true, + "no_engine_reported_a_fall": true, + "no_engine_reported_shelf_contact": true, + "mean_position_error_spread_m": 0.0032, + "mean_position_error_spread_limit_m": 0.05, + "duration_spread_s": 3.098, + "duration_spread_limit_s": 5.0 + }, + "verdict": "PASS", + "full_results": [ + { + "simulator_engine": "MuJoCo", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.606, + "wall_time_seconds": 15.717, + "control_steps": 2303, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00954, + "max_position_error_m": 0.01349, + "final_pelvis_height_m": 0.9084, + "min_pelvis_height_m": 0.9084, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 1.1339, + "max_end_effector_speed_inspecting_mps": 0.3355, + "final_torso_roll_rad": 0.0343, + "final_torso_pitch_rad": 0.0971, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.0051, + "best_error_m": 0.0051, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01004, + "best_error_m": 0.01004, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.01349, + "best_error_m": 0.01347, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } + }, + { + "simulator_engine": "PyBullet", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 4.978, + "wall_time_seconds": 3.294, + "control_steps": 2489, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.01218, + "max_position_error_m": 0.01979, + "final_pelvis_height_m": 0.9398, + "min_pelvis_height_m": 0.9395, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 3.1092, + "max_end_effector_speed_inspecting_mps": 0.3663, + "final_torso_roll_rad": 0.0001, + "final_torso_pitch_rad": 0.0011, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.01005, + "best_error_m": 0.01005, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01979, + "best_error_m": 0.01979, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00671, + "best_error_m": 0.00671, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } + }, + { + "simulator_engine": "Webots", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 7.704, + "control_steps": 3850, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00898, + "max_position_error_m": 0.01233, + "final_pelvis_height_m": 0.911, + "min_pelvis_height_m": 0.8982, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 6.2037, + "max_end_effector_speed_inspecting_mps": 2.8167, + "final_torso_roll_rad": -0.0011, + "final_torso_pitch_rad": -0.0023, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.00582, + "best_error_m": 0.00269, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01233, + "best_error_m": 0.01233, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00879, + "best_error_m": 0.00879, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } + } + ] +} diff --git a/docs/evidence/tunnel-e2e-evidence.json b/docs/evidence/tunnel-e2e-evidence.json new file mode 100644 index 000000000..45efa3f96 --- /dev/null +++ b/docs/evidence/tunnel-e2e-evidence.json @@ -0,0 +1,226 @@ +{ + "demo": "atlas_tunnel_e2e", + "transport": "Zenoh (peer mode)", + "action_topic": "robot/tunnel/action", + "result_topic": "robot/tunnel/result", + "robot_id": "atlas-sim-01", + "skill_id": "inspect_shelf", + "price_raw": "1000", + "network": "eip155:84532", + "steps": [ + { + "step": "forged-authorization", + "http_status": 402, + "published_to_zenoh": false, + "executed": false, + "settlement_eligible": false, + "settled_on_chain": false, + "facilitator_reachable": true, + "facilitator_is_valid": false, + "facilitator_reason": "invalid_exact_evm_signature" + }, + { + "step": "unpaid", + "http_status": 402, + "published_to_zenoh": false, + "executed": false, + "settlement_eligible": false, + "settled_on_chain": false, + "error_code": "MISSING_PAYMENT", + "message": "No payment header provided. Payment required." + }, + { + "step": "wrong-amount", + "http_status": 400, + "published_to_zenoh": false, + "executed": false, + "settlement_eligible": false, + "settled_on_chain": false, + "error_code": "AMOUNT_MISMATCH", + "message": "Expected amount 1000, got 500." + }, + { + "step": "bad-tx-hash", + "http_status": 400, + "published_to_zenoh": false, + "executed": false, + "settlement_eligible": false, + "settled_on_chain": false, + "error_code": "MALFORMED_TX_HASH", + "message": "Settlement reference is not an EVM transaction hash: '0xnot-a-transaction-hash'." + }, + { + "step": "paid", + "action_id": "act-paid-be5f390f", + "http_status": 200, + "published_to_zenoh": true, + "executed": true, + "settlement_eligible": true, + "settled_on_chain": false, + "settlement_tx_hash": null, + "correlation": { + "action_id": "act-paid-be5f390f", + "robot_id": "atlas-sim-01", + "skill_id": "inspect_shelf", + "params_hash": "sha256:c8ffca4f7fc2422f10eafa79be097d9f51e5dbcd515ae8d312d610a9b79aa931", + "idempotency_key": "idem-act-paid-be5f390f", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" + }, + "result_status": "success", + "targets_completed": 3, + "targets_total": 3, + "shelf_contacts": 0, + "fall_detected": false + }, + { + "step": "replay", + "http_status": 400, + "published_to_zenoh": false, + "executed": false, + "settlement_eligible": false, + "settled_on_chain": false, + "error_code": "REPLAY_DETECTED", + "message": "Replay detected for tx 0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b." + }, + { + "step": "bad-params", + "action_id": "act-bad-params-263625f0", + "http_status": 200, + "published_to_zenoh": true, + "executed": true, + "settlement_eligible": false, + "settled_on_chain": false, + "settlement_tx_hash": null, + "correlation": { + "action_id": "act-bad-params-263625f0", + "robot_id": "atlas-sim-01", + "skill_id": "inspect_shelf", + "params_hash": "sha256:9b7375a85444f3ea6065ed721c455794f68a8a1c2d2f18beeb6ea9c39c7e835b", + "idempotency_key": "idem-act-bad-params-263625f0", + "profile_id": "boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1" + }, + "result_status": "failure", + "targets_completed": null, + "targets_total": null, + "shelf_contacts": null, + "fall_detected": null + } + ], + "settlement_ledger": { + "entries": [ + { + "action_id": "act-unpaid-423ac32e", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_UNPAID", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180125.9888208, + "reason": "No payment provided. HTTP 402 returned.", + "execution_success": false + }, + { + "action_id": "act-wrong-amount-7f0f0778", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_REJECTED", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180125.9889266, + "reason": "Expected amount 1000, got 500.", + "execution_success": false + }, + { + "action_id": "act-bad-tx-hash-3a5b0db8", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_REJECTED", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180125.9890015, + "reason": "Settlement reference is not an EVM transaction hash: '0xnot-a-transaction-hash'.", + "execution_success": false + }, + { + "action_id": "act-paid-be5f390f", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SETTLEMENT_ELIGIBLE", + "receipt_tx_hash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "1000", + "asset": "USDC", + "network": "eip155:84532", + "block_number": 0, + "timestamp": 1787180125.9890544, + "reason": "Execution succeeded and settlement is authorised by policy. No on-chain transaction was made in this run.", + "execution_success": true + }, + { + "action_id": "act-replay-1ca6f861", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_REPLAY", + "receipt_tx_hash": "", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "", + "asset": "", + "network": "", + "block_number": 0, + "timestamp": 1787180134.1572123, + "reason": "Replay detected for tx 0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b.", + "execution_success": false + }, + { + "action_id": "act-bad-params-263625f0", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "status": "SKIPPED_FAILURE", + "receipt_tx_hash": "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "settlement_tx_hash": null, + "settled_on_chain": false, + "amount": "1000", + "asset": "USDC", + "network": "eip155:84532", + "block_number": 0, + "timestamp": 1787180134.1572351, + "reason": "Correlated tunnel result reported failure.", + "execution_success": false + } + ], + "total": 6, + "settled_on_chain": 0, + "settlement_eligible_not_on_chain": 1, + "skipped_failure": 1, + "skipped_unpaid": 1 + }, + "payment_verification": "protocol_checks_only", + "settlement": "eligible_not_on_chain", + "settlement_tx_hash": null, + "accepted_receipt": "synthetic; this demo proves the transport and the refusal paths. real-paid-run.json is the artifact where USDC actually moves.", + "invariants": { + "exactly one request became eligible for settlement": true, + "the eligible request is the payment-validated one": true, + "the payment-validated request completed every target": true, + "unverified payments never reached Zenoh": true, + "every executed request was correlated by action_id": true, + "the live facilitator refused a forged authorization": true + } +} diff --git a/docs/evidence/tunnel-e2e-terminal.txt b/docs/evidence/tunnel-e2e-terminal.txt new file mode 100644 index 000000000..f0d57bdf9 --- /dev/null +++ b/docs/evidence/tunnel-e2e-terminal.txt @@ -0,0 +1,50 @@ +==================================================================== + Atlas payment-validated action over the real Zenoh transport +==================================================================== + bridge listening on robot/tunnel/action as atlas-sim-01 + payment verification: protocol checks only + + [forged-authorization] asking the live x402 facilitator + facilitator reachable : True + isValid : False + reason : invalid_exact_evm_signature + nothing published to Zenoh, simulator never touched + + [unpaid] action_id=act-unpaid-423ac32e + x402 rejected -> HTTP 402 (MISSING_PAYMENT) + nothing published to Zenoh, simulator never touched + + [wrong-amount] action_id=act-wrong-amount-7f0f0778 + x402 rejected -> HTTP 400 (AMOUNT_MISMATCH) + nothing published to Zenoh, simulator never touched + + [bad-tx-hash] action_id=act-bad-tx-hash-3a5b0db8 + x402 rejected -> HTTP 400 (MALFORMED_TX_HASH) + nothing published to Zenoh, simulator never touched + + [paid] action_id=act-paid-be5f390f + x402 verified -> publishing on robot/tunnel/action + result correlated on robot/tunnel/result: status=success + targets=3/3 collisions=0 fall=False + settlement eligible (nothing moved on chain) + + [replay] action_id=act-replay-1ca6f861 + x402 rejected -> HTTP 400 (REPLAY_DETECTED) + nothing published to Zenoh, simulator never touched + + [bad-params] action_id=act-bad-params-263625f0 + x402 verified -> publishing on robot/tunnel/action + result correlated on robot/tunnel/result: status=failure + not eligible for settlement + +==================================================================== + INVARIANTS +==================================================================== + [OK] exactly one request became eligible for settlement + [OK] the eligible request is the payment-validated one + [OK] the payment-validated request completed every target + [OK] unverified payments never reached Zenoh + [OK] every executed request was correlated by action_id + [OK] the live facilitator refused a forged authorization + + evidence written to docs\evidence\tunnel-e2e-evidence.json diff --git a/docs/evidence/webots-inspection-episode.json b/docs/evidence/webots-inspection-episode.json new file mode 100644 index 000000000..c8635a809 --- /dev/null +++ b/docs/evidence/webots-inspection-episode.json @@ -0,0 +1,70 @@ +{ + "simulator_engine": "Webots", + "robot_model": "Boston Dynamics Atlas v4", + "model_source": "openai/roboschool @ d32bcb2 \u2014 atlas_v4_with_multisense.urdf (MIT)", + "base": "free-standing (no weld, no external support)", + "task": "inspect_shelf", + "policy_id": "atlas-shelf-inspection-dls-v1", + "status": "success", + "success": true, + "completion_reason": "sequence_complete", + "safe_stop_applied": false, + "sim_duration_seconds": 7.704, + "control_steps": 3850, + "targets_total": 3, + "targets_completed": 3, + "mean_position_error_m": 0.00898, + "max_position_error_m": 0.01233, + "final_pelvis_height_m": 0.911, + "min_pelvis_height_m": 0.8982, + "fall_threshold_m": 0.7, + "fall_detected": false, + "shelf_contacts": 0, + "max_end_effector_speed_mps": 6.2037, + "max_end_effector_speed_inspecting_mps": 2.8167, + "final_torso_roll_rad": -0.0011, + "final_torso_pitch_rad": -0.0023, + "final_phase": "DONE", + "policy_state": { + "policy_id": "atlas-shelf-inspection-dls-v1", + "controller": "state_machine + damped_least_squares_resolved_rate", + "phase": "DONE", + "chain": [ + "r_arm_shz", + "r_arm_shx", + "r_arm_ely", + "r_arm_elx" + ], + "targets_total": 3, + "targets_completed": 3, + "per_target": [ + { + "name": "shelf-top", + "reached": true, + "final_error_m": 0.00582, + "best_error_m": 0.00269, + "control_steps": 250 + }, + { + "name": "shelf-middle", + "reached": true, + "final_error_m": 0.01233, + "best_error_m": 0.01233, + "control_steps": 250 + }, + { + "name": "shelf-lower", + "reached": true, + "final_error_m": 0.00879, + "best_error_m": 0.00879, + "control_steps": 250 + } + ], + "parameters": { + "dls_damping": 0.12, + "step_gain": 0.006, + "max_joint_step_rad": 0.01, + "reach_timeout_steps": 1500 + } + } +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/README.md b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/README.md new file mode 100644 index 000000000..fead51622 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/README.md @@ -0,0 +1,67 @@ +# Atlas — paid shelf inspection (Tier 1) + +Payment-gated, policy-driven shelf inspection on a free-standing +**Boston Dynamics Atlas v4**, validated in MuJoCo, PyBullet and Webots R2025a +from one pinned robot description. + +![Atlas shelf inspection](../../../../../../docs/evidence/atlas-shelf-inspection.gif) + +| | | +| --- | --- | +| Skill | `inspect_shelf` | +| Policy | `atlas-shelf-inspection-dls-v1` | +| Robot | Atlas v4, fetched from `openai/roboschool@d32bcb2` (MIT), nothing vendored | +| Base | Free-standing — no weld, no external support | +| Result | 3/3 targets on all three engines, 9.0–12.2 mm mean error, 0 falls, 0 collisions | +| Bridge | [`bridge/boston_dynamics/atlas_bridge`](../../../../../../bridge/boston_dynamics/atlas_bridge) | + +The full measurement write-up, including the reach envelope the shelf geometry +is derived from, is in [`validation-report.md`](validation-report.md). Raw +artefacts and their checksums are listed in +[`evidence/evidence-manifest.yaml`](evidence/evidence-manifest.yaml). + +## Sequence + +``` +STAND ──▶ REACH(t) ──▶ VERIFY(t) ──▶ … ──▶ RETURN ──▶ DONE + ▲ │ + └────────────┘ hold broken → re-converge +``` + +Each control tick re-solves a damped least-squares resolved-rate step from the +measured end-effector pose. There is no recorded trajectory in the bridge. + +## Payment invariant + +| Case | HTTP | Executed | Settled | +| --- | --- | --- | --- | +| No payment | 402 | no | none | +| Wrong amount / asset / network | 402 | no | none | +| Missing any identity field | 400 | no | none — nothing published to Zenoh | +| Valid payment | **202** accepted | asynchronously | **after** the result, only on success | +| … and the episode reached 3/3 | — | yes | **0.001 USDC settled** | +| … and the episode failed, timed out or was stopped | — | reported `failed` | none | +| Replayed receipt or a reused idempotency key | 202 | no second actuation | none | + +Acceptance is about the request, not the outcome: `202` says the action was taken +in, and the terminal result is read from `GET /action/{action_id}/status`. +Settlement follows that result and never precedes it. A replay is refused by the +bridge and surfaces in the correlated result rather than as an HTTP code, because +the tunnel has already answered by then. + +Reviewer-facing expectations, each mapped to the test that enforces it, are in +[`../tests/skill-contract.test.yaml`](../tests/skill-contract.test.yaml). + +## On-chain settlement + +A real settlement of this skill on Base Sepolia: +[`0x2b3b71d0…c0f39`](https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39) +— **0.001 USDC**, the price this catalogue publishes, block 45706216, status +success. It is bound to the action it paid for: the authorization nonce is +`keccak256("act-paid-de66513f791b")`, which the USDC contract records in its +`AuthorizationUsed` event, so a reviewer can recompute it from the action id +alone. `settlement_evidence.py` reads the transaction out of +`real-paid-run.json` and re-checks it against a public RPC into +[`docs/evidence/onchain-settlement.json`](../../../../../../docs/evidence/onchain-settlement.json), +failing if the amount, asset, payer, payee or binding is not what this profile +declares. Testnet only; no key material is stored in this repository. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/evidence/evidence-manifest.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/evidence/evidence-manifest.yaml new file mode 100644 index 000000000..6cc5e74f2 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/evidence/evidence-manifest.yaml @@ -0,0 +1,167 @@ +schemaVersion: evidence-manifest.v1 +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +skillId: inspect_shelf +policyId: atlas-shelf-inspection-dls-v1 +robot: + model: Boston Dynamics Atlas v4 + source: https://github.com/openai/roboschool + commit: d32bcb2b35b94168b5ce27233ca62f3c8678886f + license: MIT + vendoredAssets: none +base: free-standing +engines: + - name: MuJoCo + status: validated + - name: PyBullet + status: validated + - name: Webots R2025a + status: validated +artifacts: + - path: docs/evidence/mujoco-inspection-episode.json + description: "MuJoCo episode metrics for the free-standing inspection run" + bytes: 2021 + sha256: 891589272615182bb67cf1a6f19ced3251d62a40fcb227055c54b9f724f2e5bf + - path: docs/evidence/pybullet-inspection-episode.json + description: "PyBullet episode metrics for the same task and controller" + bytes: 2026 + sha256: 3be0e9ffac131bcac884381b7a62d710114d182ef7f2aae69008f22f86722d86 + - path: docs/evidence/webots-inspection-episode.json + description: "Webots R2025a episode metrics for the same task and controller" + bytes: 1994 + sha256: 8d3115316ec9dbb33d1b139bdfd5f8c33607e9fc9d0b1d9221c7cfdd88bcaca8 + - path: docs/evidence/sim2sim-validation.json + description: "Cross-simulator comparison and verdict" + bytes: 10430 + sha256: f95dcb9b315c107157fdb09d8ef7d11767bd4e6a6912969296afbf8b55b6579f + - path: docs/evidence/reach-envelope.json + description: "Measured reach envelope the shelf targets are chosen from" + bytes: 12552 + sha256: 2aab090fb2e8cdb353b53900ceb43dc027d6f1384dfc7a6b6eba635d1cabdbe3 + - path: docs/evidence/go-tunnel-e2e-evidence.json + description: "Unpaid and forged actions refused by the repository's real Go tunnel" + bytes: 1157 + sha256: 77e08dec75f3caa23246f54b6df3b5a34a536947ad79bad98fdb640c7b9abdc6 + - path: docs/evidence/go-tunnel-e2e-terminal.txt + description: "Terminal transcript of the Go tunnel run" + bytes: 938 + sha256: 688c502f07e007525149bf1636f23fb7ad5378b10386fa581e0729d72019d99b + - path: docs/evidence/tunnel-e2e-evidence.json + description: "Paid action over the real Zenoh transport, with the settlement ledger" + bytes: 7945 + sha256: 8deeb0c02fee1c176daaf51fe24b375eb6c4de820ecbf121c9fef8ebeda6cccc + - path: docs/evidence/tunnel-e2e-terminal.txt + description: "Terminal transcript of that same Zenoh run" + bytes: 2196 + sha256: 8ca56c67b3d02e9d13772aa0bfe46a5040ff3811a33d724b10aab88d1c2915ae + - path: docs/evidence/onchain-settlement.json + description: "Base Sepolia settlement transaction, re-read from a public RPC" + bytes: 3008 + sha256: 46984f5242af9bca2468194e9d961eecc13347e72a734c5dd2151e6afdc3e29b + - path: docs/evidence/demo-e2e-evidence.json + description: "x402 gate: unpaid, invalid, paid-and-settled, replayed" + bytes: 3187 + sha256: e222a0f25bdd0282d1140c9dfe183e447cb196a238aa35c60e9751187e1cf2e2 + - path: docs/evidence/atlas-shelf-inspection.gif + description: "Rendered MuJoCo episode, same run as the metrics" + bytes: 2413354 + sha256: b115c21d8b917afd3067d16de8ae01ca7b405cbe2c8e35436d03da63329001c5 + - path: docs/evidence/real-paid-run.json + description: "One real payment: live facilitator acceptance, execution, and 0.001 USDC settled on Base Sepolia, bound to the action_id" + bytes: 6726 + sha256: b5a69f69115f1b9bccedefa230fba9948f177908a0dbfe0ce8ac6ec8113c6e42 + - path: docs/evidence/fabric-relay-e2e.json + description: "The whole path with nothing stood in for: hosted Fabric relay, robot and skill discovery, priced 402, paid action, Zenoh, Atlas 3/3, relay terminal status, and 0.001 USDC settled bound to the action_id" + bytes: 12322 + sha256: 9c5d4f45a51cb82f792b258776c5a726aff6916f702f180dfa529ea2bc44ae96 + - path: docs/evidence/fabric-relay-failure.json + description: "The unhappy path through the same relay: a paid action refused by the declared parameter bounds, answered 202 immediately, reported failed with its real reason and correlated by action_id — and never charged, confirmed by the token contract's own record of spent authorizations" + bytes: 7946 + sha256: 2e01b79d8b8e495c5d7ac08e39423b634e334e38ac6c0b4c795247022befe451 + - path: docs/evidence/atlas-paid-action.gif + description: "One continuous recording of one paid action, rendered from inside the scored episode: the terminal trace from discovery through the 402, the signed authorization, the 202, the correlated result and the settlement, beside the simulator rendering the episode that payment bought" + bytes: 4023382 + sha256: 8ea1e7428d2db0fad4a03e23c52bc749dc883e90218615207dbb180f4b496693 +settlement: + paid_action: + note: >- + The settlement for the demonstrated action. The authorization nonce is + keccak256(action_id), so this transfer is verifiably the one that paid for + this action rather than any other; the same value appears in the token's + AuthorizationUsed event. + action_id: "act-paid-de66513f791b" + network: Base Sepolia (eip155:84532) + asset: USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e + transaction: "0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39" + explorer: https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39 + block: 45706216 + transferred: 0.001 USDC + payer: "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc" + payee: "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" + authorization_nonce: "0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de" + submitted_by: "0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf" + submitted_by_note: >- + The x402 facilitator's own address, as advertised by its /supported + endpoint. The facilitator submitted the transaction and paid the gas, + which is what EIP-3009 is for and why the payer needs no ETH. It is also + independent evidence that this settlement went through the live + facilitator rather than being self-submitted. + fabric_relay_action: + note: >- + The settlement for the action that ran through the hosted Fabric relay. + Its nonce is keccak256(action_id) too, so it is bound to that action and + not to any other. The tunnel answered 202 immediately and settled from a + background watcher, only after the correlated result reported every target + reached. + action_id: "atlas-inspect-1787197727" + transaction: "0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940" + explorer: https://sepolia.basescan.org/tx/0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940 + block: 45714728 + transferred: 0.001 USDC + payer: "0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc" + payee: "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" + authorization_nonce: "0x38f39e2c3e8e663fb9ce14bbbd4ae1825a17d68972ee17fc5f802df06a61169d" + submitted_by: "0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf" + recorded_action: + note: >- + The settlement shown in atlas-paid-action.gif, and the episode in that + recording is the one this payment bought: the frames are rendered from + inside the scored run rather than from a second run alongside it. Located + on chain by its nonce rather than by its hash — keccak256(action_id) is + enough to find the AuthorizationUsed event, which is the binding working + in the direction a reviewer would use it. + action_id: "atlas-inspect-1787242629" + transaction: "0x7fc1c1ff21e535376bf22b3409b751d769dadfdcb2491d3803c5712905b5b197" + explorer: https://sepolia.basescan.org/tx/0x7fc1c1ff21e535376bf22b3409b751d769dadfdcb2491d3803c5712905b5b197 + block: 45737187 + transferred: 0.001 USDC + authorization_nonce: "0x97b7245b38f9b8d38bd14485f3a912f57621039933d21480dae6fb7a44aa0c40" + refused_action_not_settled: + note: >- + The failing counterpart of the run above, through the same relay with the + same wallet. The tunnel accepted the request with 202, the bridge refused + the action, and nothing + settled. The proof is the token's own record rather than the absence of a + hash on our side: authorizationState(payer, keccak256(action_id)) is + false, so USDC itself says this action was never paid for. + action_id: "atlas-inspect-1787197752" + execution: "refused — INVALID_DURATION" + tunnel_response: 202 (accepted; the outcome arrives on the status endpoint) + transaction: null + authorization_spent_on_chain: false + note: >- + Testnet only, no monetary value. Both payer wallets are disposable test + wallets and are treated as compromised; no key material is stored in this + repository. +reproduce: + - pip install -r bridge/boston_dynamics/atlas_bridge/requirements.txt + - python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model + - python -m bridge.boston_dynamics.atlas_bridge.runner + - python -m bridge.boston_dynamics.atlas_bridge.pybullet_runner + - python -m bridge.boston_dynamics.atlas_bridge.sim2sim + - python -m bridge.boston_dynamics.atlas_bridge.demo_e2e + - python -m bridge.boston_dynamics.atlas_bridge.demo_tunnel + - python -m bridge.boston_dynamics.atlas_bridge.demo_go_tunnel --tunnel + - python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence + - SETTLEMENT_PRIVATE_KEY=... python -m bridge.boston_dynamics.atlas_bridge.real_paid_run --payer + - SETTLEMENT_PRIVATE_KEY=... python -m bridge.boston_dynamics.atlas_bridge.demo_fabric_e2e --tunnel + - SETTLEMENT_PRIVATE_KEY=... python -m bridge.boston_dynamics.atlas_bridge.evidence_recording --tunnel diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/validation-report.md b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/validation-report.md new file mode 100644 index 000000000..95dcb1c2f --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/docs/validation-report.md @@ -0,0 +1,595 @@ +# Validation report — Atlas Tier 1 shelf inspection + +Profile: `boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1` +Skill: `inspect_shelf` · Policy: `atlas-shelf-inspection-dls-v1` + +Every number below is produced by a command in this repository and written to +[`docs/evidence/`](../../../../../../docs/evidence). Nothing is transcribed by +hand. + +Skill discovery and pricing are published in +[`skill-catalog.json`](../skill-catalog.json), generated from `skills.yaml` so +the two cannot disagree. + +## 1. Evidence ladder + +The claims are built up in layers, so a reviewer can see exactly which rung each +one rests on. + +| Rung | What it establishes | Artefact | +| --- | --- | --- | +| Model integrity | The robot is the pinned Atlas v4 and the code addresses its actuators correctly | `tests/test_model_integrity.py` | +| Kinematics | The shared URDF Jacobian agrees with MuJoCo's own | `tests/test_kinematics.py` | +| Reach envelope | Where free-standing Atlas can reach without losing balance | `reach-envelope.json` | +| Free-standing task | The full inspection sequence succeeds on its own feet | `mujoco-inspection-episode.json` | +| Cross-simulator | The same robot, task and controller agree on two further engines | `pybullet-inspection-episode.json`, `webots-inspection-episode.json`, `sim2sim-validation.json` | +| Payment gate | Execution is gated by x402 and settles only on success | `demo-e2e-evidence.json` | +| Tunnel flow | A payment-validated action reaches the robot over the real Zenoh transport | `tunnel-e2e-evidence.json` | +| Facilitator | A forged authorization is refused by the live x402 facilitator | `tunnel-e2e-evidence.json`, `tests/test_facilitator.py` | +| Real Go tunnel | The repository's own tunnel refuses unpaid and forged actions | `go-tunnel-e2e-evidence.json` | +| Idempotency | A payment-validated action actuates the robot once, across restarts | `tests/test_idempotency.py` | +| Paid action | A live-facilitator-verified payment executed the skill and settled 0.001 USDC, bound to the `action_id` | `real-paid-run.json` | +| One recording of all of it | The 402, the signed authorization, the 202, the episode, the correlated result and the settlement — one pass, one action, with the frames rendered from inside the scored episode rather than a second run | `atlas-paid-action.gif` | +| Full relay path | Discovery, priced 402, paid action, execution and settlement through the **hosted Fabric relay** with nothing stood in for | `fabric-relay-e2e.json` | +| Failure is not charged | A refused action returns an error, settles nothing, and the token contract confirms the authorization was never spent | `fabric-relay-failure.json` | +| On-chain settlement | The settlement transaction, re-read from a public RPC | `real-paid-run.json`, `onchain-settlement.json` | + +## 2. Model integrity + +Atlas v4 is fetched, never vendored: + +| Field | Value | +| --- | --- | +| Upstream | `openai/roboschool` | +| Commit | `d32bcb2b35b94168b5ce27233ca62f3c8678886f` | +| File | `roboschool/models_robot/atlas_description/urdf/atlas_v4_with_multisense.urdf` | +| License | MIT | +| Mesh assets committed | none — upstream collision geometry is analytic | + +Actuator addressing is read back out of the compiled model and checked against +the URDF's own effort limits (890 N·m knee, 840 N·m hip pitch, 112 N·m elbow). +A mismatch in the joint set or in any effort limit raises immediately; +`test_actuator_validation_fails_loudly_on_drift` pins that behaviour. + +## 3. Reach envelope + +`python -m bridge.boston_dynamics.atlas_bridge.reach_envelope` drives the arm to +a 6 × 6 grid of offsets from the settled home pose and records, per probe, +whether the arm converged to 30 mm and whether Atlas was still standing. + +``` +vert \ fwd +0.06 +0.12 +0.18 +0.21 +0.24 +0.30 + +0.20 OK OK OK OK fall miss + +0.10 OK OK OK miss fall fall + +0.00 OK OK OK OK OK OK + -0.06 OK OK OK OK OK OK + -0.12 OK OK OK OK fall fall + -0.20 fall fall fall fall fall fall +``` + +23 of 36 probes are usable. The reported envelope is the **largest block in +which every probe succeeded** — forward 0.06–0.18 m, vertical −0.12 to +0.20 m +(15/15) — not a bounding box around scattered successes. + +The three inspection targets sit at 0.13–0.15 m forward and −0.06 to +0.06 m +vertical, inside that block. `test_targets_stay_inside_the_validated_reach_core` +fails if a target is ever moved out of it. + +## 4. Free-standing task result + +Atlas is never welded, clamped or supported. The fall threshold is 0.70 m of +pelvis height — it stands at 0.911 m — rather than a floor-contact test. + +| Metric | MuJoCo | PyBullet | Webots R2025a | +| --- | --- | --- | --- | +| Status | success | success | success | +| Targets reached and held | 3 / 3 | 3 / 3 | 3 / 3 | +| Mean end-effector error | 9.54 mm | 12.18 mm | 8.98 mm | +| Max end-effector error | 13.49 mm | 19.79 mm | 12.33 mm | +| Min pelvis height | 0.9084 m | 0.9395 m | 0.8982 m | +| Fall detected | no | no | no | +| Shelf collisions | 0 | 0 | 0 | +| Episode duration | 4.61 s | 4.98 s | 7.70 s | +| Completion reason | sequence_complete | sequence_complete | sequence_complete | + +Per-target accuracy is recorded individually in each episode JSON. + +MuJoCo runs are bit-identical across repeats (`test_run_is_repeatable`), so the +repeatability claim is checked rather than asserted. + +## 5. Sim-to-sim + +`sim2sim-validation.json` runs the task on every available engine and compares +them. One pinned URDF drives all three; the Jacobian comes from that URDF rather +than from any engine, so the controller is identical everywhere. What differs is +the physics engine and the joint servo: MuJoCo applies an explicit PD law with a +gravity feedforward, while PyBullet and Webots apply joint-position commands +saturated at the same URDF effort limits. + +| Check | Limit | Measured | +| --- | --- | --- | +| Every engine completed every target | required | yes | +| No engine reported a fall | required | yes | +| No engine reported a shelf collision | required | yes | +| Mean-error spread across the three engines | ≤ 50 mm | 3.20 mm | +| Duration spread across the three engines | ≤ 5.0 s | 3.10 s | + +Verdict: **PASS**. + +Webots is generated from the same pinned URDF: `webots_env.py` converts it to a +PROTO and writes the world from the same `task.py` geometry, so its shelf and its +robot are the ones the other two engines use. Where Webots is not installed — +GitHub's runners, for instance — `sim2sim` reports the engine as +`unavailable_no_webots_installation` and computes the verdict from the engines +that actually ran. A missing engine never turns a failing comparison into a +passing one. + +Two engine-specific details are worth stating plainly, because they are the only +places the three runs differ: + +* **Servo implementation.** MuJoCo integrates an explicit PD law; PyBullet and + Webots use their own implicit joint servos. All three saturate at the same + URDF effort limits. An explicit PD at these gains is numerically unstable at + the other engines' fixed steps. +* **Webots servo gain.** Webots' position servo is a velocity-level controller + whose default gain (P=10) tracks too slowly to hold a 182 kg humanoid — the + ankle lags, the torso pitches, and Atlas topples after about a second. P=120 + holds the stance at 0.898 m. This was measured, not guessed. + +Everything above the servo — the robot, the task geometry, the state machine, +the Jacobian and the gravity feedforward — is shared code. + +### 5.1 End-effector speed + +An earlier revision of this report quoted 5.76 m/s for MuJoCo. That number was +wrong, and the way it was wrong is worth recording. It read +`data.cvel[hand][:3]`; MuJoCo lays `cvel` out as `[angular; linear]`, so it +reported the hand's angular rate in rad/s as a speed in m/s. All three engines +now measure the same thing — how far the hand actually moved in one control +step — and `test_reported_speed_matches_the_hand_actually_moving` checks the +reported figure against the hand's own displacement, because nothing in the task +fails when this metric is wrong. + +| Engine | While inspecting (REACH/VERIFY) | Episode peak | Shelf contacts | +| --- | --- | --- | --- | +| MuJoCo | 0.336 m/s | 1.134 m/s | 0 | +| PyBullet | 0.366 m/s | 3.109 m/s | 0 | +| Webots R2025a | 2.817 m/s | 6.204 m/s | 0 | + +Two separate effects, neither of them a physical claim about Atlas: + +* **The episode peak is a RETURN artefact.** `RETURN` assigns the stance pose + straight into the joint targets instead of going through `_servo`, so the + rate limit that shapes `REACH` does not apply and only the actuator effort + limits bound the retraction. That peak happens after the last target has been + verified, so it is reported separately from the speed reached near the shelf. +* **The spread between engines is servo stiffness, not motion planning.** The + rate limit is `MAX_JOINT_STEP` = 0.01 rad per 2 ms control step — 5 rad/s in + joint space, which at the ~0.6 m shoulder-to-hand lever is a ceiling of about + 3 m/s at the hand. Webots' stiff position servo tracks the rate-limited target + closely enough to reach that ceiling; the softer PD servos in MuJoCo and + PyBullet lag well behind it. The bound is the same in all three; how much of + it gets used is an engine property. + +So the ceiling is set in joint space by the controller, not by a Cartesian speed +limit, and it is not tuned per engine. What is asserted here is only what was +measured: no engine touched the shelf, and the closest the hand came to a target +without contact was 4.9 mm (MuJoCo) and 6.7 mm (PyBullet). + +## 6. Payment gate + +`demo-e2e-evidence.json` walks the full gate: + +| Step | HTTP | Executed | Settlement | +| --- | --- | --- | --- | +| No payment | 402 | no | none | +| Wrong amount | 400 | no | none | +| Protocol-valid payment, 3/3 targets | 200 | yes | **eligible, not on chain** | +| Replayed receipt | 409 | no | none | + +That `409` is this in-process relay's answer, and only its own: it replies +synchronously, so it can refuse with a code. The transport demo answers `400`, +and the hosted Go tunnel has already answered `202` by the time the bridge +recognises the duplicate, so it reports `DUPLICATE_ACTION` on the status +endpoint instead. None of the three actuates the robot a second time. + +This demo runs in-process and holds no wallet, so the successful step is +recorded as `SETTLEMENT_ELIGIBLE` with `settlement_tx_hash: null`. That +distinction is enforced by the ledger rather than by discipline: `SETTLED` +requires a settlement transaction hash **and** the block containing it, and +`test_settled_requires_a_real_transaction` fails if a receipt alone can earn it. +The receipt a caller presents is an input that authorises the run; it is not a +transfer, and an artifact that reports one as the other claims money moved when +it did not. + +Where value really moves is section 8. + +37 payment-safety tests cover the receipt validation, the settlement ledger and +the relay gating. A safely stopped episode returns `completion_reason: +safe_stopped` and `success: false`, so it can never settle. + +## 7. Payment-validated action over the tunnel + +`demo_e2e.py` proves the payment invariants in-process. `demo_tunnel.py` proves +the transport, with nothing stubbed between the gate and the simulator: + +``` +payment-validated action request + -> x402 verification (tunnel side) + -> Zenoh robot/tunnel/action + -> Atlas bridge + -> MuJoCo inspection episode + -> Zenoh robot/tunnel/result + -> correlation by action_id + -> settlement, only on success +``` + +| Request | Verified | Published to Zenoh | Executed | Settled | +| --- | --- | --- | --- | --- | +| No payment | no (402) | **no** | no | no | +| Wrong amount | no (400) | **no** | no | no | +| Malformed `txHash` | no (400) | **no** | no | no | +| Valid receipt † | protocol checks only | yes | 3/3 targets | eligible, **not on chain** | +| Replayed receipt | no (400) | **no** | no | no | +| Undeclared parameter | protocol checks only | yes | rejected by the bridge | no | + +† A synthetic receipt. This walkthrough proves the **transport**, so its accepted +row is protocol-level and settles nothing — the artifact records +`payment_verification: protocol_checks_only` and `settlement: eligible_not_on_chain`. +Sections 8 and 9 are where a real payment is verified and real value moves. + +### Two gates, and which one each result went through + +The bridge applies payment checks in two layers, and this report is deliberate +about which layer proved what: + +1. **Protocol checks** — amount, asset, network, expiry, the shape of the + settlement reference, and replay. Cheap, and they reject the obvious cases + before anything else happens. The walkthrough's accepted request passes + these. +2. **Facilitator verification** — the only check that can distinguish a real + authorization from a well-formed forgery, because only the facilitator + recovers the signer. + +The forgery case is proven against the **live** facilitator: a payload with a +correct amount, asset, network and a perfectly shaped signature is refused with +`invalid_exact_evm_signature`. A dedicated test asserts the uncomfortable half of +that too — the protocol checks *do* accept the same payload, which is exactly why +the facilitator layer exists. Verification also **fails closed**: an unreachable +facilitator is treated as a rejection, never as an approval. + +**Which layer proved what, in one place.** This walkthrough's accepted request +passed the protocol layer only; it holds no wallet, so it settles nothing. The +accepting side of facilitator verification is **not** proven here — it is proven +in section 8.1 (`real-paid-run.json`, `isValid: true` from the live facilitator, +0.001 USDC settled) and again in section 9 through the hosted relay. No claim in +this section depends on a funded wallet, and none of it should be read as the +profile's evidence for a real payment. + +The single source of truth for what has been paid for: + +| Path | Payment verification | Settlement | +| --- | --- | --- | +| `demo-e2e-evidence.json` (in-process) | protocol checks only | eligible, none | +| `tunnel-e2e-evidence.json` (Zenoh transport) | protocol checks only, plus a **live** facilitator rejection | eligible, none | +| `go-tunnel-e2e-evidence.json` (real tunnel) | live facilitator, refusals only | none | +| `real-paid-run.json` (§8.1) | **live facilitator accepted** | **0.001 USDC on chain**, after execution | +| `fabric-relay-e2e.json` (§9) | **live facilitator accepted, via the hosted relay** | **0.001 USDC on chain**, after execution | +| `fabric-relay-failure.json` (§9.1) | live facilitator accepted | **none** — execution failed, and the token confirms the authorization was never spent | + +### Idempotency + +A payment-validated action actuates the robot once. The store is keyed on +`robot_id + skill_id + idempotency_key`, records the parameters and a payment +fingerprint alongside it, and is appended to disk so the guarantee survives a +restart: + +| Repeat | Outcome | +| --- | --- | +| Same key, same request | `duplicate` — the recorded outcome is replayed, the robot does not move | +| Same key, different parameters | refused, `IDEMPOTENCY_PARAMS_CONFLICT` | +| Same key, different payment | refused, `IDEMPOTENCY_PAYMENT_CONFLICT` | +| Same key after a restart | still one actuation | +| No key at all | not deduplicated — the caller opted out | + +Two properties matter here and both are asserted by the demo itself: + +* An unverified payment is **never published to Zenoh**, so the simulator is + unreachable without a valid receipt. +* Every executed action is answered on `robot/tunnel/result` carrying the + originating `action_id`, `robot_id`, `skill_id`, `params_hash` and + `idempotency_key`, which is what lets the tunnel correlate an asynchronous + result with the request that paid for it. + +The transcript is committed at +[`tunnel-e2e-terminal.txt`](../../../../../../docs/evidence/tunnel-e2e-terminal.txt). +Zenoh runs in peer mode, so no external router is needed: + +```bash +python -m bridge.boston_dynamics.atlas_bridge.demo_tunnel +``` + +## 8. On-chain settlement + +The gate above decides *whether* an action may settle. This section is the +receipt that one actually did — for a specific action, not in general. + +### 8.1 The paid action + +`real-paid-run.json` records one action carried the whole way: an EIP-3009 +authorization signed by a funded wallet, accepted by the **live** x402 +facilitator, executed on the robot, and settled only after the episode reported +every target reached. + +| Field | Value | +| --- | --- | +| Action | `act-paid-de66513f791b` | +| Facilitator verdict | `isValid: true` — live, at `https://x402.org/facilitator` | +| Robot | success, 3/3 targets, 0 shelf contacts, correlated by `action_id` | +| Settlement tx | [`0x2b3b71d0…c0f39`](https://sepolia.basescan.org/tx/0x2b3b71d0ce18554a4927e1145a704359bad35c209f632dc414926b995aac0f39) | +| Status | success (`0x1`), block 45706216, gas 85696 | +| Amount | 0.001 USDC (`1000` raw) — the price the profile declares | +| Payer | [`0xa0597a74…Fc2Dc`](https://sepolia.basescan.org/address/0xa0597a74f3C3F33797007495bc3Dc676F10Fc2Dc) | +| Payee | [`0x7b916325…C3e8`](https://sepolia.basescan.org/address/0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8) | +| Submitted by | [`0xd407e409…f1bf`](https://sepolia.basescan.org/address/0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf) | + +Three things about this are worth more than the transaction itself. + +**The settlement is bound to the action, cryptographically.** EIP-3009 lets the +signer pick the 32-byte authorization nonce, so this run sets it to +`keccak256(action_id)`. The token emits that nonce in its `AuthorizationUsed` +event, so the binding is on chain and anyone can check it: + +``` +keccak256("act-paid-de66513f791b") + = 0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de +AuthorizationUsed nonce in block 45706216 + = 0xaa6cf89a24e6ee6471a1dde2a1e9eee101d60213f9231132ea717affd03b47de +``` + +Without this, a receipt and an execution are two facts sitting next to each +other; with it, this transfer is provably the one that paid for this action. + +**The facilitator submitted the transaction, not us.** The sender is the +facilitator's own address, the one its `/supported` endpoint advertises. That is +independent evidence the payment went through the live facilitator rather than +being self-submitted — and it is why the payer holds no ETH: under EIP-3009 the +payer only signs, and the facilitator pays the gas. + +**Settlement followed execution.** `/verify` ran first, the robot ran second, +and `/settle` was called only because the episode reached every target. A failed +episode leaves the authorization signed and unspent, which is the behaviour the +payment policy claims. + +Re-verify the whole chain from the transaction hash alone — no trust in this +document required: + +```bash +python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence +``` + +### 8.2 What CI re-verifies on every push + +`onchain-settlement.json` is regenerated by `settlement_evidence.py`, which +reads the settlement out of `real-paid-run.json` rather than from a hard-coded +hash, so the check follows the evidence instead of drifting from it. It exits +non-zero unless the transaction succeeded **and** its `AuthorizationUsed` nonce +equals `keccak256(action_id)` — a settlement of the right size that is not bound +to the action proves the asset moved, not that this action was the reason. + +An earlier revision of this file verified a 1.0 USDC transfer that predated the +paid run and was bound to no action at all, while the profile's real settlement +was 0.001 USDC. The step was green and was checking the wrong transaction. + +The wallet that made it was funded by a CDP faucet request, +[`0xb37252fd…fdbbb`](https://sepolia.basescan.org/tx/0xb37252fda0bc30de9ce98bd1b306c131eda11a4b3fabd9ae11d487d8773fdbbb), +block 45669921. No settlement other than the ones in 8.1 and 9 is evidence for +this skill; an earlier 1.0 USDC transfer between two test wallets exists on +chain but is bound to no action and is not cited here. + +Two deliberate choices about how this is reported: + +* **No balances are recorded.** Balances change after the fact; the transaction + and its `Transfer` event do not. An artefact that claims a balance goes stale + the moment anything else touches the wallet. +* **No key material anywhere.** The payer is a disposable testnet wallet whose + key was exposed in an earlier revision of this branch; it is treated as + compromised and holds nothing of value. Base Sepolia USDC has no monetary + worth. Settlement is executed by the operator's own wallet at deploy time — + `payment-policy.yaml` keeps the payee as `` so the profile + stays portable. + +Re-verify it yourself: + +```bash +python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence +``` + +The command exits non-zero if the transaction is missing, reverted, or carries +no USDC `Transfer`. + +## 9. The whole path, with nothing stood in for + +Two substitutions ran through the sections above, and this one removes both. +`demo_go_tunnel.py` drives the real tunnel but stands in for the hosted Fabric +backend with a local WebSocket proxy; `real_paid_run.py` settles a real payment +but reaches the robot over Zenoh directly. `fabric-relay-e2e.json` records a run +where every component is the real one: + +``` +client + -> Fabric relay https://api.fabric.foundation/api/core (hosted) + -> Go tunnel this repository's binary, dialled out over WSS + -> x402 middleware -> live facilitator + -> Zenoh robot/tunnel/action + -> Atlas bridge -> MuJoCo, three inspection targets + -> Zenoh robot/tunnel/result + -> Fabric relay terminal status, correlated by action_id + -> settlement 0.001 USDC on Base Sepolia +``` + +| Step | Result | +| --- | --- | +| Action | `atlas-inspect-1787197727` | +| Robot discovery | `GET /robots/{id}/skills` → 200, robot connected | +| Skill discovery | `inspect_shelf`, `stop` | +| Price discovery | 0.001 USDC — read from the response, not assumed | +| Unpaid action | **402** from the relay, with payment requirements | +| Quoted amount | `1000` raw, matching the discovered price | +| Paid action | **202 accepted** immediately, before the robot finished | +| Execution | 3/3 targets | +| Terminal status | `succeeded`, correlated by `action_id` | +| Settlement | [`0xfd9eda75…1e6940`](https://sepolia.basescan.org/tx/0xfd9eda75ddc6c6f979eb2571e6e85ef3a6f50d670f3f8ad252107723e21e6940), block 45714728 | +| Binding | on-chain nonce = `keccak256("atlas-inspect-1787197727")` | +| Token's own record | `authorizationState(...) = true` — the authorization was spent | + +**The price is discovered, not assumed.** The payment is built from the amount +the relay returns in its 402, and the run asserts that amount equals the price +the catalogue advertises. A profile whose published price drifted from what its +tunnel charges would fail this check rather than pass it quietly. + +**Discovery answers from the profile's own catalogue.** `GET /skills` reads +`skill-catalog.json` — the file the registry publishes — so there is no second +copy of the price to drift. + +**What the tunnel gained to make this possible.** Three read-only endpoints — +`GET /robot`, `GET /skills`, `GET /action/:action_id/status`. The status +endpoint is not synthesised: the tunnel subscribes to the same +`robot/tunnel/result` topic the simulator publishes on and stores what arrives, +keyed by `action_id`; an unanswered action reads as `pending` and a failed one as +`failed`. + +### 9.1 A failed action is not paid for + +`fabric-relay-failure.json` sends a paid action whose `maxDurationSec` is below +the bound the catalogue declares, through the same relay, with the same wallet. + +| | | +| --- | --- | +| Action | `atlas-inspect-1787197752` | +| Execution | refused — `INVALID_DURATION` | +| Tunnel's answer | **HTTP 202**, immediately — acceptance is about the request, not the outcome | +| Status endpoint | `failed`, carrying that error code, correlated by `action_id` | +| Settlement | **none** — no transaction exists | +| Token's own record | `authorizationState(...) = false` | + +The last row is the one that matters. "We recorded no transaction hash" is an +absence of evidence; it proves nothing about whether the payer was charged. +EIP-3009 tokens keep their own map of spent authorization nonces, so the +question can be put to the contract instead — and because the nonce is +`keccak256(action_id)`, anyone can recompute it from the action id alone and ask +USDC directly whether this action was ever paid for. The answer is no. + +**How the guarantee is enforced.** The tunnel contract answers `202` the moment +an action is accepted, so the HTTP response cannot carry the outcome and must +not carry the payment decision either. The stock x402 gin middleware settles as +soon as a protected route answers anything under 400, which would charge the +payer on that `202` before the robot had run. The tunnel therefore replaces it +with a gate that keeps the `402`/verify half exactly as it was — an unpaid +request still gets `402` with the advertised requirements, and a payment the +live facilitator rejects still never reaches the robot — but hands a settlement +callback to the action handler instead of settling. A background watcher invokes +that callback only when the correlated result reports success; a failure or a +silent robot leaves the authorization signed and unspent, and both are readable +from the status endpoint. Eight tests in +`tunnel/internal/handlers/handlers_test.go` hold that contract without needing a +wallet or a chain, including that a refused request is never published to Zenoh +at all. + +An earlier revision of this profile settled on *acceptance* instead, before the +robot ran, and a refused action was still charged. That was measured, not +suspected, and it is what prompted the change. + +**On why the failure is a refused parameter rather than a timeout.** The +catalogue declares `maxDurationSec` minimum 5, and at 5 seconds the episode +completes all three targets — measured over three runs, not assumed. There is +therefore no in-bounds duration that produces an execution timeout, which is a +property of a well-chosen bound rather than a gap. Execution-level failures that +must not settle — falls, shelf contact, safe stop — are covered in section 6 and +by `tests/test_x402_payment_safety.py`. + +### 9.2 What this profile does not prove + +**Robot identity, the outbound client, and the payee.** Three things get +conflated here, so they are separated. + +*The outbound client.* The success criteria describe the bridge connecting out +to the relay "using `robotsdk`". There is no package by that name in this +repository; the robot-side outbound client it ships is `tunnel/`, and the relay +connection itself is `tunnel/internal/client.go`, which dials the WSS endpoint +with `gorilla/websocket`. The same module also carries +`github.com/unibaseio/aip-go-sdk`, used by `cmd/main.go` and `internal/aipagent` +for the optional authenticated AIP path — not for that transport. This profile +uses the tunnel as it is, adding three read-only endpoints and changing no +transport behaviour. The merged Tier-1 profiles use the same component. + +*The authentication handshake.* It exists in that tunnel and this profile does +not bypass it. With `AIP_ENABLED=true`, `cmd/main.go` runs +`aipauth.EnsureAuth`, which returns a bearer token and a wallet address, and +`internal/aipagent` then registers the agent with `Handle` set to the robot id +and `UserID` set to that wallet — identity bound to wallet, by the shared +tunnel, through the SDK. The demos recorded here run with it disabled, because +`EnsureAuth` drives an interactive browser authorization flow that a +reproducible, unattended demo cannot perform. So: implemented in the component +this profile uses, not exercised in these recordings, and not reimplemented +here. + +*The payee, which this profile can and does hold.* The identity the tunnel +answers for and the address it is paid to come from one configuration and are +advertised together, so a caller can see which wallet the robot it is talking to +gets paid at before paying anything: `GET /robot` returns `robot_id` and +`pay_to` from that config, the `402` quotes the same `payTo`, and the x402 +middleware refuses a payment whose `payTo` differs — it matches on scheme, +network, amount, asset **and** payee. The settlements in 8.1 and 9 landed at +exactly that address. `TestTheAdvertisedPayeeIsTheConfiguredOne` and +`TestAnUnconfiguredPayeeIsNotAdvertisedAsAnAddress` hold that half. + +What this profile therefore does **not** claim: that the recorded runs exercised +the authenticated registration, or that a robot profile could make an identity +unforgeable on its own. It cannot; that belongs to the tunnel and the gateway. + +What *is* bound cryptographically is the settlement to the action: the +authorization nonce is `keccak256(action_id)` and the token records it on chain. +That is a different property and it is proven in 8.1 and 9. + +## 10. Reproducing + +**The recorded home-pose value is version-sensitive, and MuJoCo is pinned +because of it.** On a commit whose only changes were to documentation, a clean +CI environment resolved MuJoCo 3.12.0 and +`test_home_pose_matches_the_recorded_geometry` measured the resting hand at +0.9816 m against the 0.9589 m recorded with the evidence build. That is the +observation; no claim is made here about what inside the engine changed. + +The episode itself barely moved — mean position error differed by 0.00001 m, the +pelvis floor not at all, and the task still reported 3/3 on both engines. But the +shelf coordinates in `task.py` come from a reach envelope measured at that +resting pose, so the committed geometry and the build that produced it have to +travel together. `requirements.txt` therefore bounds `mujoco`, and a reviewer +cloning this repository gets the numbers recorded in `docs/evidence` rather than +whichever numbers today's release produces. + +`numpy` and `pybullet` are deliberately left floored. Bounding them as well would +be pinning on suspicion rather than on an observation, and it would cost anyone +building this more than it buys. + +Worth stating plainly because the tempting fix was the wrong one: widening the +test's tolerance would have turned a working reproducibility check into a +decoration, and that check is what found this — on the least likely commit, +which is exactly when nobody is looking. + + +```bash +pip install -r bridge/boston_dynamics/atlas_bridge/requirements.txt +python -m bridge.boston_dynamics.atlas_bridge.download_atlas_model +python -m pytest bridge/boston_dynamics/atlas_bridge/tests -q +python -m bridge.boston_dynamics.atlas_bridge.runner +python -m bridge.boston_dynamics.atlas_bridge.pybullet_runner +python -m bridge.boston_dynamics.atlas_bridge.sim2sim +python -m bridge.boston_dynamics.atlas_bridge.demo_e2e +python -m bridge.boston_dynamics.atlas_bridge.demo_tunnel +python -m bridge.boston_dynamics.atlas_bridge.settlement_evidence +``` + +The runner exits non-zero unless every target was reached and held, the robot is +still standing, and nothing touched the shelf. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.inspect_shelf.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.inspect_shelf.json new file mode 100644 index 000000000..ecdfa40e7 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.inspect_shelf.json @@ -0,0 +1,25 @@ +{ + "payload": { + "action": "inspect_shelf", + "skill_id": "inspect_shelf", + "robot_id": "atlas-sim-01", + "action_id": "act-atlas-inspect-0001", + "idempotency_key": "idem-atlas-inspect-0001", + "params": { "maxDurationSec": 30 } + }, + "transaction_details": { + "payment_payload": { + "amount": "1000", + "asset": "USDC", + "network": "eip155:84532", + "txHash": "0x", + "payer": "0x", + "payee": "0x" + }, + "payment_requirements": { + "scheme": "exact", + "priceUSDC": "0.001" + } + }, + "timestamp": "2026-08-19T00:00:00Z" +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.stop.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.stop.json new file mode 100644 index 000000000..3ff648179 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/examples/action-envelope.stop.json @@ -0,0 +1,25 @@ +{ + "payload": { + "action": "stop", + "skill_id": "stop", + "robot_id": "atlas-sim-01", + "action_id": "act-atlas-stop-0001", + "idempotency_key": "idem-atlas-stop-0001", + "params": {} + }, + "transaction_details": { + "payment_payload": { + "amount": "1000", + "asset": "USDC", + "network": "eip155:84532", + "txHash": "0x", + "payer": "0x", + "payee": "0x" + }, + "payment_requirements": { + "scheme": "exact", + "priceUSDC": "0.001" + } + }, + "timestamp": "2026-08-19T00:00:00Z" +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/execution-mapping.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/execution-mapping.yaml new file mode 100644 index 000000000..ac4dfdda8 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/execution-mapping.yaml @@ -0,0 +1,46 @@ +schemaVersion: execution-mapping.v1 +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +transport: + type: zenoh + actionTopic: robot/tunnel/action + resultTopic: robot/tunnel/result + metricsTopic: robot/boston_dynamics_atlas/metrics + readyTopic: robot/boston_dynamics_atlas/ready +paramsHashFormat: "sha256:" +mappings: + inspect_shelf: + task: multi_target_shelf_inspection + output: >- + MuJoCo episode on free-standing Atlas v4; PyBullet and Webots R2025a + cross-engine validation of the same URDF, task and controller. + policy: >- + atlas-shelf-inspection-dls-v1 — STAND, then REACH/VERIFY per target, then + RETURN. Recomputed from the measured end-effector pose and the measured + joint configuration at every control tick; no recorded trajectory. + controller: >- + Damped least-squares resolved-rate loop on the right-arm chain, sharing one + URDF-derived Jacobian across all engines. MuJoCo applies a PD torque law + with gravity feedforward; PyBullet and Webots apply joint-position commands + saturated at the same URDF effort limits. + inputs: [end_effector_position, joint_positions, pelvis_pose, simulator_time] + metrics: + - targets_completed + - mean_position_error_m + - max_position_error_m + - min_pelvis_height_m + - fall_detected + - shelf_contacts + - max_end_effector_speed_mps + - max_end_effector_speed_inspecting_mps + - sim_duration_seconds + - policy_id + limits: + maxDurationSec: 60 + maxJointStepRad: 0.01 + stop: + task: safe_stop + output: every actuator released and simulated velocity zeroed + result: >- + correlated success for stop; an active inspection returns failure with + completion_reason safe_stopped + metrics: [safe_stop_applied, completion_reason] diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/functions.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/functions.yaml new file mode 100644 index 000000000..37e254d15 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/functions.yaml @@ -0,0 +1,82 @@ +schemaVersion: agent-functions.v1 +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +functions: + - name: get_robot_profile + method: GET + url: /robot + returns: + "200": robot_id, profile_id, network, pay_to, and the skill IDs on offer + - name: list_robot_skills + method: GET + url: /skills + returns: + "200": >- + robot_id and the profile's own skill catalogue — skill_id, description, + payment_required, price_usdc and the parameter schema, read from + skill-catalog.json so a quoted price cannot drift from the published one + "503": >- + the catalogue is unreadable, so the tunnel says it cannot list skills + rather than serving an empty or invented one + - name: get_action_status + method: GET + url: /action/{action_id}/status + returns: + "200": >- + action_id, robot_id, skill_id, params_hash, idempotency_key, profile_id + and state — pending until the simulator answers, then succeeded or + failed with the result it reported + "400": action_id missing from the path + - name: request_robot_action + method: POST + url: /action + body: + action: string + skill_id: string # required; never inferred from action + robot_id: string + params: object # bounded duration + action_id: string # required; the result is correlated on it + idempotency_key: string + payment: + unpaidStatus: 402 + paymentRequiredHeader: PAYMENT-REQUIRED + - name: submit_paid_robot_action + method: POST + url: /action + headers: + PAYMENT-SIGNATURE: string + body: same as request_robot_action + returns: + "202": >- + accepted/pending, carrying action_id and status_url; the terminal + outcome is read from GET /action/{action_id}/status + "400": >- + a malformed or undecodable request or payment at the HTTP layer, or a + body missing any of action_id, robot_id, skill_id and idempotency_key; + nothing is published to Zenoh. Recorded in go-tunnel-e2e-evidence.json + as the answer to a forged authorization + "402": >- + payment required, or a payment the gate or the live facilitator refuses; + answered with the requirements in the PAYMENT-REQUIRED header, and where + a refusal has a reason, a PAYMENT-RESPONSE header carrying it. Recorded + in go-tunnel-e2e-evidence.json as the answer to an unpaid action + "500": the action event could not be encoded; nothing is published + "502": the robot transport is unavailable, or publishing to Zenoh failed + returns_note: >- + These are the codes this route produces, and no others. Outcomes the robot + decides — a refused skill, a replayed idempotency key, a failed or + timed-out episode — cannot appear here, because the route has already + answered 202 by the time they are known. They arrive as the state and + error_code of GET /action/{action_id}/status, which is where a caller must + look for them. + settlement: >- + Deferred until the correlated result carries status success. A failed, + timed-out or safely stopped episode never settles. + how_settlement_is_deferred: >- + The stock x402 gin middleware settles as soon as a protected route answers + anything under 400, which would charge the payer on the 202 above, before + the robot had run. The tunnel therefore replaces it with a gate that keeps + the 402/verify half unchanged but hands a settlement callback to the action + handler; a background watcher invokes that callback only after the + correlated result reports success. An earlier revision of this profile + settled on acceptance and a refused action was charged for — measured, not + assumed, which is why the gate exists. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/payment-policy.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/payment-policy.yaml new file mode 100644 index 000000000..ef63eb7c5 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/payment-policy.yaml @@ -0,0 +1,27 @@ +schemaVersion: payment-policy.v1 +provider: x402 +network: eip155:84532 +asset: + symbol: USDC + address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + decimals: 6 +policies: + - skillId: inspect_shelf + required: true + priceUSDC: "0.001" + paymentHeader: PAYMENT-SIGNATURE + - skillId: stop + required: true + priceUSDC: "0.001" + paymentHeader: PAYMENT-SIGNATURE +# Payee is deployment-configured by ROBO_PAYEE_ADDRESS; keep profiles portable. +payTo: "" +settlement: + facilitator: https://x402.org/facilitator + scheme: exact + settleOnStatus: success + rule: >- + Settlement is deferred until a correlated robot/tunnel/result carries status + success. Failed, timed-out, rejected, or safely stopped inspection episodes + never settle. A paid stop request may settle only after its own correlated + safe-stop success result. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/robot.profile.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/robot.profile.yaml new file mode 100644 index 000000000..35533df97 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/robot.profile.yaml @@ -0,0 +1,35 @@ +schemaVersion: robot-profile.v1 +vendor: boston-dynamics +robotModel: atlas +robotType: humanoid +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +profileVersion: 1.0.0 +runtime: + transport: zenoh + actionTopic: robot/tunnel/action + resultTopic: robot/tunnel/result + metricsTopic: robot/boston_dynamics_atlas/metrics + readyTopic: robot/boston_dynamics_atlas/ready + bridge: bridge/boston_dynamics/atlas_bridge +simulation: + scope: simulator-only + primaryEngine: MuJoCo + validationEngines: + - engine: MuJoCo + status: validated + - engine: PyBullet + status: validated + - engine: Webots R2025a + status: validated + base: free-standing + # One description drives all three engines. Collision geometry upstream is + # analytic, so no mesh assets are vendored into this repository. + model: + source: https://github.com/openai/roboschool + commit: d32bcb2b35b94168b5ce27233ca62f3c8678886f + directory: roboschool/models_robot/atlas_description + file: urdf/atlas_v4_with_multisense.urdf + license: MIT +maintainers: + - github: EslaM-X +status: experimental diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skill-catalog.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skill-catalog.json new file mode 100644 index 000000000..dfa8ceb94 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skill-catalog.json @@ -0,0 +1,23 @@ +[ + { + "skill_id": "inspect_shelf", + "description": "Drive free-standing Atlas through a three-point shelf inspection. A state machine sequences STAND, REACH and VERIFY per target while a damped least-squares resolved-rate loop closes on the measured end-effector pose. Succeeds only when every target is reached and held, the robot is still standing, and nothing touched the shelf.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 60, + "default": 30 + } + } + }, + { + "skill_id": "stop", + "description": "Interrupt an active episode, release every actuator and freeze the robot. An interrupted inspection returns a correlated safe_stopped failure, so that action can never settle. Priced like any other skill because it arrives over the same paid-action channel; safety does not depend on it, since the episode is bounded by maxDurationSec and the bridge stops on its own when the budget expires.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } +] diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skills.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skills.yaml new file mode 100644 index 000000000..b3daad289 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/skills.yaml @@ -0,0 +1,36 @@ +schemaVersion: robot-skills.v1 +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +skills: + - skillId: inspect_shelf + description: >- + Drive free-standing Atlas through a three-point shelf inspection. A state + machine sequences STAND, REACH and VERIFY per target while a damped + least-squares resolved-rate loop closes on the measured end-effector pose. + Succeeds only when every target is reached and held, the robot is still + standing, and nothing touched the shelf. + params: + maxDurationSec: + type: number + min: 5 + max: 60 + default: 30 + paymentRequired: true + priceUSDC: "0.001" + # Only limits the bridge actually enforces are declared here. Joint torque + # is clamped by the URDF effort limits, the per-step joint increment is + # clamped by the controller, and the duration is validated on the way in. + movementLimits: + maxDurationSec: 60 + maxJointStepRad: 0.01 + jointTorqueLimits: from upstream URDF effort limits + - skillId: stop + description: >- + Interrupt an active episode, release every actuator and freeze the robot. + An interrupted inspection returns a correlated safe_stopped failure, so + that action can never settle. Priced like any other skill because it + arrives over the same paid-action channel; safety does not depend on it, + since the episode is bounded by maxDurationSec and the bridge stops on its + own when the budget expires. + params: {} + paymentRequired: true + priceUSDC: "0.001" diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/tests/skill-contract.test.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/tests/skill-contract.test.yaml new file mode 100644 index 000000000..95abfa43d --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1/tests/skill-contract.test.yaml @@ -0,0 +1,337 @@ +schemaVersion: skill-contract-test.v1 +profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 +description: >- + Reviewer-facing contract for inspect_shelf. Every expectation below is tied to + something executable — a Python test under bridge/boston_dynamics/atlas_bridge/tests, + a Go test under tunnel/internal/handlers, a demo that exits non-zero when its + invariants fail, or a verification script that re-reads the chain. The + enforcedBy field on each case names which one, so nothing here rests on this + document alone. +cases: + - name: unpaid_request_is_refused + given: an inspect_shelf request with no payment header + expect: + httpStatus: 402 + executed: false + settled: false + enforcedBy: tests/test_x402_payment_safety.py::TestRelayPaymentGating::test_unpaid_request_returns_402 + + - name: wrong_amount_is_refused + given: a receipt whose amount does not match the skill price + expect: + httpStatus: 400 + executed: false + settled: false + enforcedBy: tests/test_x402_payment_safety.py::TestRelayPaymentGating::test_valid_payment_failure_no_settlement + + - name: replayed_receipt_is_refused_in_process + given: a receipt whose transaction hash was already settled + path: in-process relay — answers synchronously, so it can refuse with a code + expect: + httpStatus: 409 + executed: false + settled: false + enforcedBy: tests/test_x402_payment_safety.py::TestRelayPaymentGating::test_replay_rejected + + - name: replay_on_the_hosted_path_surfaces_in_the_result + given: a paid action reusing an idempotency key already actuated + path: hosted relay -> Go tunnel -> Zenoh -> bridge + expect: + httpStatus: 202 + executed: false + settled: false + errorCode: DUPLICATE_ACTION + enforcedBy: tests/test_idempotency.py::test_same_key_actuates_the_robot_once + note: >- + The tunnel has already answered 202 by the time the bridge recognises the + duplicate, so the refusal cannot be an HTTP code on POST /action. It + arrives as the state and error_code of GET /action/{action_id}/status, and + the robot is not actuated a second time. + + - name: in_process_valid_request_executes_and_becomes_eligible + given: a protocol-valid receipt and an episode that reaches every target + path: in-process relay — holds no wallet, so it settles nothing + expect: + httpStatus: 200 + executed: true + settled: false + settlement: eligible_not_on_chain + targetsCompleted: 3 + enforcedBy: tests/test_x402_payment_safety.py::TestRelayPaymentGating::test_valid_payment_success_settles + + - name: hosted_paid_action_is_accepted_immediately + given: a paid action submitted through the hosted Fabric relay + path: hosted relay -> Go tunnel -> Zenoh -> simulator + expect: + httpStatus: 202 + accepted: true + actionIdPresent: true + statusUrlPresent: true + settledImmediately: false + enforcedBy: tunnel/internal/handlers/handlers_test.go::TestPostActionAnswersImmediatelyWithAccepted + note: >- + Acceptance is about the request, not the outcome. The terminal result is + read from GET /action/{action_id}/status and the settlement follows it. + + - name: hosted_paid_action_settles_only_after_success + given: a paid action whose correlated result reports every target reached + expect: + settled: true + settlementFollowsResult: true + amountRaw: "1000" + authorizationNonce: keccak256(action_id) + enforcedBy: tunnel/internal/handlers/handlers_test.go::TestSettlementFollowsSuccess + + - name: hosted_failed_action_is_never_settled + given: a paid action the bridge refuses, or a robot that never answers + expect: + httpStatus: 202 + settled: false + settlementTransaction: null + authorizationSpentOnChain: false + enforcedBy: tunnel/internal/handlers/handlers_test.go::TestAFailedEpisodeIsNeverSettled + note: >- + Proven on chain as well as in test: authorizationState(payer, + keccak256(action_id)) on the USDC contract is false for the refused + action in fabric-relay-failure.json. + + - name: stopped_episode_never_settles + given: a stop request raised mid-episode + expect: + completionReason: safe_stopped + success: false + settled: false + enforcedBy: tests/test_inspection_task.py::test_safe_stop_halts_the_robot_mid_episode + + - name: task_actually_completes + given: a full inspection episode in MuJoCo + expect: + targetsCompleted: 3 + maxPositionErrorM: "< 0.03" + fallDetected: false + shelfContacts: 0 + minPelvisHeightM: "> 0.70" + enforcedBy: tests/test_inspection_task.py::test_every_target_is_reached_and_held + + - name: success_flag_is_derived_from_metrics + given: any episode result + expect: >- + success equals (all targets completed) and (no fall) and (no shelf + contact) and (not safe stopped) + enforcedBy: tests/test_inspection_task.py::test_success_flag_agrees_with_the_measured_metrics + + - name: controller_is_closed_loop + given: two controllers fed different measured end-effector poses + expect: different joint commands and different reported errors + enforcedBy: tests/test_inspection_task.py::test_controller_is_closed_loop_not_a_replayed_trajectory + + - name: run_is_repeatable + given: two MuJoCo episodes from the same build + expect: identical target count, mean error and minimum pelvis height + enforcedBy: tests/test_inspection_task.py::test_run_is_repeatable + + - name: actuator_mapping_cannot_drift + given: an effort limit or joint set that disagrees with the pinned URDF + expect: ValueError raised at environment construction + enforcedBy: tests/test_model_integrity.py::test_actuator_validation_fails_loudly_on_drift + + - name: shared_jacobian_matches_the_engine + given: the settled stance + expect: URDF-derived Jacobian within 5e-3 of MuJoCo's own + enforcedBy: tests/test_kinematics.py::test_jacobian_matches_mujoco + + - name: targets_stay_inside_the_validated_envelope + given: the configured inspection targets + expect: every target inside the measured conservative reach core + enforcedBy: tests/test_inspection_task.py::test_targets_stay_inside_the_validated_reach_core + + - name: settlement_is_real_on_chain + given: the settlement recorded by the profile's paid run + expect: + network: Base Sepolia (eip155:84532) + status: success + event: Transfer(address,address,uint256) on the USDC contract + amount: 0.001 USDC + amountRaw: "1000" + matchesPublishedPrice: skills.yaml priceUsdc + authorizationNonce: keccak256(action_id) + enforcedBy: bridge/boston_dynamics/atlas_bridge/settlement_evidence.py + note: >- + The transaction is read out of real-paid-run.json rather than pinned, then + re-read from a public RPC. The command exits non-zero if it is missing, + reverted, carries no transfer, or its AuthorizationUsed nonce is not + keccak256 of the action id it claims to have paid for — a transfer of the + right size that is bound to no action proves only that the asset moved. + + - name: bridge_executes_the_registered_skill + given: the profile's own inspect_shelf action envelope + expect: + executed: true + status: success + skillId: inspect_shelf + profileId: boston-dynamics.atlas.mujoco-pybullet-webots-shelf-inspection.v1 + enforcedBy: tests/test_bridge_contract.py::test_shipped_envelope_drives_the_real_simulator + note: >- + The bridge, the registry and the runner must name the same skill. An + earlier revision left the bridge wired to a previous skill and the module + did not even import; these tests exist so that cannot recur. + + - name: result_is_correlated_to_the_request + given: any executed action + expect: >- + the result echoes action_id, robot_id, skill_id, params_hash, + idempotency_key and profile_id + enforcedBy: tests/test_bridge_contract.py::test_result_echoes_every_correlation_field + + - name: protocol_valid_action_reaches_the_robot_over_zenoh + given: a protocol-valid x402 receipt published on robot/tunnel/action + expect: + transport: Zenoh + executed: true + settled: false + settlement: eligible_not_on_chain + targetsCompleted: 3 + enforcedBy: bridge/boston_dynamics/atlas_bridge/demo_tunnel.py + note: >- + Unverified payments are never published to Zenoh at all, so the simulator + is not reachable without a valid receipt. + + - name: malformed_settlement_reference_is_refused + given: a receipt whose txHash is not a 32-byte EVM transaction hash + expect: + httpStatus: 400 + errorCode: MALFORMED_TX_HASH + executed: false + enforcedBy: tests/test_x402_payment_safety.py::TestTransactionHashValidation + + - name: price_is_one_decision + given: the registry, the bridge and the settlement layer + expect: one price (0.001 USDC = 1000 raw) and one USDC contract address + enforcedBy: tests/test_x402_payment_safety.py::TestPaymentContractConsistency + + - name: forged_authorization_is_refused_by_the_facilitator + given: >- + a payment payload that passes every protocol check but was never signed + expect: + facilitatorIsValid: false + invalidReason: invalid_exact_evm_signature + executed: false + settled: false + enforcedBy: tests/test_facilitator.py::test_live_facilitator_rejects_a_forged_authorization + note: >- + Protocol checks alone accept this payload — a dedicated test asserts that + too — so the facilitator is the only gate that can refuse it. + + - name: facilitator_failure_fails_closed + given: the facilitator is unreachable + expect: + valid: false + errorCode: FACILITATOR_REJECTED + enforcedBy: tests/test_facilitator.py::test_unreachable_facilitator_refuses_the_payment + note: An unreachable facilitator must never authorise an action. + + - name: paid_action_actuates_the_robot_once + given: the same idempotency key arriving twice + expect: + actuations: 1 + secondStatus: duplicate + errorCode: DUPLICATE_ACTION + enforcedBy: tests/test_idempotency.py::test_same_key_actuates_the_robot_once + + - name: idempotency_survives_a_restart + given: a repeat arriving after the bridge is restarted + expect: + actuations: 1 + enforcedBy: tests/test_idempotency.py::test_guarantee_survives_a_restart + note: The store is reloaded from disk, so a retry after a crash is still one. + + - name: idempotency_conflicts_are_refused + given: the same key with different parameters or a different payment + expect: + executed: false + errorCode: IDEMPOTENCY_PARAMS_CONFLICT | IDEMPOTENCY_PAYMENT_CONFLICT + enforcedBy: tests/test_idempotency.py + + - name: tunnel_schema_accepts_both_spellings + given: an action envelope using camelCase identity fields + expect: the same parse result as snake_case, with params_hash recomputed + enforcedBy: tests/test_bridge_contract.py::test_camel_case_envelope_is_understood + note: >- + The tunnel forwards the caller's body verbatim, so the casing is the + caller's. A snake_case-only bridge would pass every local test and then + ignore a real Fabric request. + + - name: real_go_tunnel_refuses_unpaid_action + given: an action sent through the repository's own Go tunnel with no payment + expect: + httpStatus: 402 + paymentRequirementsAdvertised: true + reachedSimulator: false + enforcedBy: bridge/boston_dynamics/atlas_bridge/demo_go_tunnel.py + note: >- + The decision is made by the tunnel's upstream x402 gin middleware, not by + any Python code in this profile. + + - name: real_go_tunnel_refuses_forged_payment + given: a structurally valid but unsigned authorization through the Go tunnel + expect: + httpStatus: 400 + reachedSimulator: false + enforcedBy: bridge/boston_dynamics/atlas_bridge/demo_go_tunnel.py + note: The middleware consults the live facilitator before admitting the action. + + - name: concurrent_duplicates_actuate_once + given: eight threads racing with the same idempotency key + expect: + actuations: 1 + enforcedBy: tests/test_idempotency.py::test_concurrent_duplicates_actuate_once + note: >- + The claim is taken atomically; a check-then-record guard passes every + sequential test and still lets simultaneous retries through. + + - name: identity_fields_are_mandatory + given: an envelope missing action_id, robot_id, skill_id or idempotency_key + expect: + executed: false + errorCode: MISSING_IDENTITY + enforcedBy: tests/test_bridge_contract.py::test_missing_identity_is_refused + note: A result nobody can correlate is worse than no result. + + - name: an_incomplete_identity_never_reaches_zenoh + given: a paid request missing any one of the four identity fields + expect: + httpStatus: 400 + published: 0 + executed: false + enforcedBy: tunnel/internal/handlers/handlers_test.go::TestNothingReachesTheRobotUntilTheRequestIsAccepted + note: >- + Refused at the tunnel, before the transport. Checking after publishing + would put a message on the wire that the bridge is going to reject anyway. + + - name: skill_id_is_never_inferred + given: an envelope that omits skill_id + expect: + executed: false + errorCode: MISSING_IDENTITY + enforcedBy: tests/test_bridge_contract.py::test_missing_skill_id_is_not_inferred_from_the_action + note: >- + An earlier revision defaulted skill_id to the action name, which made the + identity requirement unenforceable. + + - name: params_hash_matches_the_published_format + given: any action, including one with no parameters + expect: params_hash is always "sha256:<64 hex>" + enforcedBy: tests/test_bridge_contract.py::test_params_hash_always_matches_the_published_format + note: execution-mapping.yaml declares paramsHashFormat "sha256:". + + - name: duplicate_reports_the_original_outcome + given: a repeat of a completed idempotency key + expect: + executed: false + firstStatus: success | failure | safe_stopped + enforcedBy: tests/test_idempotency.py::test_duplicate_is_answered_with_the_recorded_outcome + + - name: rejection_reasons_are_not_all_replays + given: a payment refused for a reason other than replay + expect: ledger status SKIPPED_REJECTED rather than SKIPPED_REPLAY + enforcedBy: bridge/boston_dynamics/atlas_bridge/payment.py::SettlementLedger.record_rejected diff --git a/tunnel/cmd/main.go b/tunnel/cmd/main.go index 76c962ef2..cb77b53e5 100644 --- a/tunnel/cmd/main.go +++ b/tunnel/cmd/main.go @@ -3,7 +3,9 @@ package main import ( "context" "encoding/json" + "errors" "flag" + "net/http" "os" "os/signal" "syscall" @@ -203,16 +205,37 @@ func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logge }, } - router.Use(ginmw.X402Payment(ginmw.Config{ - Routes: routes, - Facilitator: facilitatorClient, - Schemes: []ginmw.SchemeConfig{ - {Network: x402.Network(cfg.Network), Server: evm.NewExactEvmScheme()}, - }, - Timeout: 30 * time.Second, - })) + // The stock gin middleware settles as soon as the handler returns anything + // under 400. The tunnel contract answers 202 the moment an action is + // accepted, long before the robot has finished, so that middleware would + // charge the payer for work that may still fail. This gate does the same + // 402/verify half synchronously and hands the settlement to the handler, + // which runs it only once the correlated result reports success. + paymentServer := x402http.Newx402HTTPResourceServer( + routes, x402.WithFacilitatorClient(facilitatorClient), + ) + paymentServer.Register(x402.Network(cfg.Network), evm.NewExactEvmScheme()) + { + initCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := paymentServer.Initialize(initCtx); err != nil { + logger.Warn("failed to initialise the x402 payment server", zap.Error(err)) + } + cancel() + } + router.Use(deferredSettlementGate(paymentServer, logger)) h := handlers.NewHandlers(logger) + h.RobotID = cfg.RobotID + h.Network = cfg.Network + h.PayTo = cfg.EVMPayeeAddress + h.ProfileID = os.Getenv("ROBOT_PROFILE_ID") + h.SkillCatalogPath = os.Getenv("SKILL_CATALOG_PATH") + // Results are recorded from Zenoh so the status endpoint answers from real + // execution. Without it the tunnel could only ever report "pending". + if err := h.StartResultSubscriber(); err != nil { + logger.Warn("action status will stay pending: result subscriber failed", + zap.Error(err)) + } RegisterAllRoutes(router, h) // Serve the AIP A2A contract (/.well-known/agent-card.json, /invoke, ...) @@ -224,7 +247,92 @@ func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logge return router } +// deferredSettlementGate replaces the stock x402 middleware for one reason: the +// stock one settles on response, and this tunnel answers 202 before the robot +// has run. It performs the same work up to and including verification — an +// unpaid request still gets 402 with the advertised requirements, and a payment +// the facilitator rejects still never reaches the robot — but instead of +// settling it puts a handlers.SettleFunc in the request context. The action +// handler calls that function only after the simulator reports success, so a +// failed or timed-out episode leaves the authorization signed and unspent. +func deferredSettlementGate(server *x402http.HTTPServer, logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + reqCtx := x402http.HTTPRequestContext{ + Adapter: ginmw.NewGinAdapter(c), + Path: c.Request.URL.Path, + Method: c.Request.Method, + } + if !server.RequiresPayment(reqCtx) { + c.Next() + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + result := server.ProcessHTTPRequest(ctx, reqCtx, nil) + + switch result.Type { + case x402http.ResultNoPaymentRequired: + c.Next() + case x402http.ResultPaymentError: + for key, value := range result.Response.Headers { + c.Header(key, value) + } + if result.Response.IsHTML { + body, _ := result.Response.Body.(string) + c.Data(result.Response.Status, "text/html; charset=utf-8", []byte(body)) + } else { + c.JSON(result.Response.Status, result.Response.Body) + } + c.Abort() + case x402http.ResultPaymentVerified: + if result.PaymentPayload == nil || result.PaymentRequirements == nil { + logger.Warn("verified payment carried no payload or requirements; refusing") + c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{ + "error": "payment verification incomplete", + }) + return + } + c.Set("x402_payload", *result.PaymentPayload) + c.Set("x402_requirements", *result.PaymentRequirements) + // Copied by value: the settle callback outlives this request, and + // gin recycles the context as soon as the 202 goes out. + payload := *result.PaymentPayload + requirements := *result.PaymentRequirements + declared := result.DeclaredExtensions + c.Set("x402_settle", handlers.SettleFunc( + func(settleCtx context.Context) (*handlers.SettlementRecord, error) { + settlement := server.ProcessSettlement( + settleCtx, payload, requirements, nil, nil, declared, + ) + if settlement == nil { + return nil, errors.New("settlement returned no result") + } + if !settlement.Success { + reason := settlement.ErrorReason + if reason == "" { + reason = "settlement failed" + } + return nil, errors.New(reason) + } + return &handlers.SettlementRecord{ + Transaction: settlement.Transaction, + Network: string(settlement.Network), + Payer: settlement.Payer, + }, nil + })) + c.Next() + default: + c.Next() + } + } +} + // RegisterAllRoutes registers all real handlers on the router. func RegisterAllRoutes(router *gin.Engine, h *handlers.Handlers) { + // Discovery and status are read-only; POST /action is unchanged. + router.GET("/robot", h.GetRobotProfile) + router.GET("/skills", h.GetSkills) router.POST("/action", h.PostAction) + router.GET("/action/:action_id/status", h.GetActionStatus) } diff --git a/tunnel/internal/handlers/discovery.go b/tunnel/internal/handlers/discovery.go new file mode 100644 index 000000000..58d4b4992 --- /dev/null +++ b/tunnel/internal/handlers/discovery.go @@ -0,0 +1,426 @@ +package handlers + +// Minimal tunnel integration required to expose a robot profile and to +// correlate asynchronous execution results through the relay. +// +// POST /action already publishes a paid action onto Zenoh, but a caller has no +// way to ask what robot is on the other end, what it can do, what that costs, +// or how a submitted action ended. Those three questions are what the relay +// needs answered to complete a paid action, so this file adds exactly three +// read-only endpoints and nothing else: +// +// GET /robot what robot is connected +// GET /skills what it can do and what each skill costs +// GET /action/:action_id/status how a submitted action ended +// +// The status is not synthesised here. The tunnel subscribes to the same +// robot/tunnel/result topic the simulator publishes on and stores what it +// receives, keyed by action_id. An action nobody has answered for is reported +// as pending; an action that failed is reported as failed. Reporting anything +// else would make the endpoint a decoration rather than a status. + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/eclipse-zenoh/zenoh-go/zenoh" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const ( + // RobotResultTopic carries the simulator's answer to a published action. + RobotResultTopic = "robot/tunnel/result" + + statePending = "pending" + stateSucceeded = "succeeded" + stateFailed = "failed" + stateTimeout = "timeout" +) + +// : How long a settlement call may take before it is abandoned. A settlement +// : that never returns must not hold a goroutine open for ever. +const settlementTimeout = 90 * time.Second + +// SettleFunc settles an already-verified payment. The payment gate injects it +// into the request context; the action handler calls it only after the +// simulator reports success, which is what keeps a failed action unpaid. +type SettleFunc func(ctx context.Context) (*SettlementRecord, error) + +// SettlementRecord is what a completed settlement leaves behind. +type SettlementRecord struct { + Transaction string `json:"transaction"` + Network string `json:"network"` + Payer string `json:"payer"` +} + +// Skill is one entry of the robot's published catalogue. The catalogue is the +// profile's own skill-catalog.json — the same file the registry publishes — so +// discovery cannot drift from what the profile declares. +type Skill struct { + SkillID string `json:"skill_id"` + Description string `json:"description"` + PaymentRequired bool `json:"payment_required"` + PriceUSDC string `json:"price_usdc"` + Params json.RawMessage `json:"params,omitempty"` +} + +// ActionStatus is what a caller gets back for one submitted action. +type ActionStatus struct { + ActionID string `json:"action_id"` + RobotID string `json:"robot_id,omitempty"` + SkillID string `json:"skill_id,omitempty"` + State string `json:"state"` + ParamsHash string `json:"params_hash,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Settled bool `json:"settled"` + Settlement *SettlementRecord `json:"settlement,omitempty"` + SettlementError string `json:"settlement_error,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +// resultEnvelope is the shape the simulator bridge publishes. +type resultEnvelope struct { + ActionID string `json:"action_id"` + RobotID string `json:"robot_id"` + SkillID string `json:"skill_id"` + ParamsHash string `json:"params_hash"` + IdempotencyKey string `json:"idempotency_key"` + ProfileID string `json:"profile_id"` + Status string `json:"status"` + Result json.RawMessage `json:"result"` +} + +type statusStore struct { + mu sync.RWMutex + entries map[string]ActionStatus + waiters map[string][]chan ActionStatus +} + +func newStatusStore() *statusStore { + return &statusStore{ + entries: make(map[string]ActionStatus), + waiters: make(map[string][]chan ActionStatus), + } +} + +func (s *statusStore) put(status ActionStatus) { + s.mu.Lock() + s.entries[status.ActionID] = status + waiting := s.waiters[status.ActionID] + delete(s.waiters, status.ActionID) + s.mu.Unlock() + // Buffered by one, so a waiter that has already timed out cannot block the + // subscriber callback. + for _, ch := range waiting { + ch <- status + close(ch) + } +} + +// settled records the outcome of the settlement attempt against an action that +// already has a result, so the status endpoint can report both halves. +func (s *statusStore) settled(actionID string, record *SettlementRecord, failure string) { + s.mu.Lock() + defer s.mu.Unlock() + status, ok := s.entries[actionID] + if !ok { + status = ActionStatus{ActionID: actionID} + } + status.Settled = record != nil + status.Settlement = record + status.SettlementError = failure + status.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + s.entries[actionID] = status +} + +func (s *statusStore) get(actionID string) (ActionStatus, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + status, ok := s.entries[actionID] + return status, ok +} + +// subscribe registers interest in an action's result before it is published, +// and returns a channel that receives the answer exactly once. Registering +// after publishing is a race the simulator wins whenever it answers quickly, +// and losing that race is indistinguishable from a timeout. +func (s *statusStore) subscribe(actionID string) <-chan ActionStatus { + ch := make(chan ActionStatus, 1) + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.entries[actionID]; ok { + ch <- existing + close(ch) + return ch + } + s.waiters[actionID] = append(s.waiters[actionID], ch) + return ch +} + +// unsubscribe drops a waiter that will never be answered — registered before a +// publish that then failed, so nothing is coming. Without this the channel and +// its entry outlive the request that made them. +func (s *statusStore) unsubscribe(actionID string, ch <-chan ActionStatus) { + s.mu.Lock() + defer s.mu.Unlock() + waiting := s.waiters[actionID] + kept := waiting[:0] + for _, existing := range waiting { + if (<-chan ActionStatus)(existing) != ch { + kept = append(kept, existing) + } + } + if len(kept) == 0 { + delete(s.waiters, actionID) + } else { + s.waiters[actionID] = kept + } +} + +// awaitResult waits for a subscribed answer, or gives up. The boolean +// distinguishes "the robot said it failed" from "the robot never answered": +// both refuse settlement, but only one of them is an execution result. +func awaitResult(done <-chan ActionStatus, timeout time.Duration) (ActionStatus, bool) { + select { + case status, ok := <-done: + return status, ok + case <-time.After(timeout): + return ActionStatus{}, false + } +} + +// actionIdentity reads the identity fields and the episode budget out of the +// request body. Both spellings are accepted because the relay forwards the +// caller's body verbatim. +type actionIdentityFields struct { + ActionID string + RobotID string + SkillID string + IdempotencyKey string + BudgetSeconds float64 +} + +// missing names the first identity field the request left out. The simulator +// bridge refuses an envelope without all four, so publishing one only puts a +// message on the wire that is going to be rejected at the other end — and an +// invalid request is supposed to reach neither Zenoh nor the robot. +func (f actionIdentityFields) missing() string { + for _, field := range []struct { + name string + value string + }{ + {"action_id", f.ActionID}, + {"robot_id", f.RobotID}, + {"skill_id", f.SkillID}, + {"idempotency_key", f.IdempotencyKey}, + } { + if field.value == "" { + return field.name + } + } + return "" +} + +func actionIdentity(body []byte) actionIdentityFields { + var envelope struct { + ActionID string `json:"action_id"` + ActionIDCamel string `json:"actionId"` + RobotID string `json:"robot_id"` + RobotIDCamel string `json:"robotId"` + SkillID string `json:"skill_id"` + SkillIDCamel string `json:"skillId"` + IdempotencyKey string `json:"idempotency_key"` + IdempotencyCamel string `json:"idempotencyKey"` + Params struct { + MaxDurationSec float64 `json:"maxDurationSec"` + } `json:"params"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return actionIdentityFields{} + } + pick := func(a, b string) string { + if a != "" { + return a + } + return b + } + return actionIdentityFields{ + ActionID: pick(envelope.ActionID, envelope.ActionIDCamel), + RobotID: pick(envelope.RobotID, envelope.RobotIDCamel), + SkillID: pick(envelope.SkillID, envelope.SkillIDCamel), + IdempotencyKey: pick(envelope.IdempotencyKey, envelope.IdempotencyCamel), + BudgetSeconds: envelope.Params.MaxDurationSec, + } +} + +// executionTimeout bounds the wait by what the caller asked the robot to spend, +// plus room for start-up and the answer coming back. ACTION_TIMEOUT_SECONDS +// overrides it for a deployment whose robot is slower than this one. +func executionTimeout(budgetSeconds float64) time.Duration { + if raw := os.Getenv("ACTION_TIMEOUT_SECONDS"); raw != "" { + if seconds, err := strconv.ParseFloat(raw, 64); err == nil && seconds > 0 { + return time.Duration(seconds * float64(time.Second)) + } + } + if budgetSeconds <= 0 { + budgetSeconds = 60 + } + return time.Duration((budgetSeconds + 45) * float64(time.Second)) +} + +var ( + resultSubMu sync.Mutex + resultSubDone bool + // The subscription is declared once for the process, so the results it + // records have to outlive any single Handlers value. setupRouter builds a + // fresh Handlers on every config-driven restart, and a per-instance store + // would leave the new one deaf: the subscriber would keep writing to the + // old store while the status endpoint read an empty new one, reporting + // every action as pending for ever. Tests override Statuses for isolation. + sharedStatuses = newStatusStore() +) + +// StartResultSubscriber begins recording simulator results so that +// GET /action/:action_id/status can answer from real execution rather than +// from an assumption. It reuses the session the publisher already opened, and +// declares the subscription once per process because setupRouter runs again on +// every config-driven restart. +func (h *Handlers) StartResultSubscriber() error { + resultSubMu.Lock() + defer resultSubMu.Unlock() + if resultSubDone { + return nil + } + // A sync.Once here would burn the single attempt on a failure and leave the + // status endpoint answering pending for the life of the process. Retrying + // on the next restart is the difference between a transient Zenoh problem + // and a permanently deaf tunnel. + if err := h.declareResultSubscriber(); err != nil { + return err + } + resultSubDone = true + return nil +} + +func (h *Handlers) declareResultSubscriber() error { + session, err := getZenohSession() + if err != nil { + return err + } + ke, err := zenoh.NewKeyExpr(RobotResultTopic) + if err != nil { + return err + } + sub, err := session.DeclareSubscriber(ke, zenoh.Closure[zenoh.Sample]{ + Call: func(sample zenoh.Sample) { + var envelope resultEnvelope + if err := json.Unmarshal(sample.Payload().Bytes(), &envelope); err != nil { + h.Logger.Warn("unparseable result envelope", zap.Error(err)) + return + } + if envelope.ActionID == "" { + return + } + state := stateFailed + if envelope.Status == "success" { + state = stateSucceeded + } + sharedStatuses.put(ActionStatus{ + ActionID: envelope.ActionID, + RobotID: envelope.RobotID, + SkillID: envelope.SkillID, + State: state, + ParamsHash: envelope.ParamsHash, + IdempotencyKey: envelope.IdempotencyKey, + ProfileID: envelope.ProfileID, + Result: envelope.Result, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }) + h.Logger.Info("recorded action result", + zap.String("action_id", envelope.ActionID), + zap.String("state", state)) + }, + }, nil) + if err != nil { + return err + } + h.resultSub = &sub + return nil +} + +// GetRobotProfile answers "what robot is on the other end of this tunnel". +func (h *Handlers) GetRobotProfile(c *gin.Context) { + skills, err := h.loadSkills() + if err != nil { + h.Logger.Warn("skill catalogue unavailable", zap.Error(err)) + } + ids := make([]string, 0, len(skills)) + for _, skill := range skills { + ids = append(ids, skill.SkillID) + } + c.JSON(http.StatusOK, gin.H{ + "robot_id": h.RobotID, + "profile_id": h.ProfileID, + "network": h.Network, + "pay_to": h.PayTo, + "skills": ids, + }) +} + +// GetSkills answers "what can it do, and what does each skill cost". +func (h *Handlers) GetSkills(c *gin.Context) { + skills, err := h.loadSkills() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "skill catalogue unavailable", "detail": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{"robot_id": h.RobotID, "skills": skills}) +} + +// GetActionStatus answers "how did that action end", from the recorded result. +func (h *Handlers) GetActionStatus(c *gin.Context) { + actionID := c.Param("action_id") + if actionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "action_id is required"}) + return + } + if status, ok := h.Statuses.get(actionID); ok { + c.JSON(http.StatusOK, status) + return + } + // Not knowing yet is a real answer, and a different one from failure. + c.JSON(http.StatusOK, ActionStatus{ + ActionID: actionID, + RobotID: h.RobotID, + State: statePending, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }) +} + +// loadSkills reads the profile's own catalogue. SKILL_CATALOG_PATH points at +// it; without the file the tunnel says so rather than inventing a catalogue. +func (h *Handlers) loadSkills() ([]Skill, error) { + path := h.SkillCatalogPath + if path == "" { + path = os.Getenv("SKILL_CATALOG_PATH") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var skills []Skill + if err := json.Unmarshal(raw, &skills); err != nil { + return nil, err + } + return skills, nil +} diff --git a/tunnel/internal/handlers/handlers.go b/tunnel/internal/handlers/handlers.go index 4aac8ec66..ddfe8660c 100644 --- a/tunnel/internal/handlers/handlers.go +++ b/tunnel/internal/handlers/handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "io" "net/http" @@ -35,19 +36,24 @@ func (z *zenohSessionPublisher) Publish(keyExpr string, payload []byte) error { var ( zenohOnce sync.Once zenohPub zenohPublisher + zenohSess zenoh.Session zenohInitError error ) -func getZenohPublisher() (zenohPublisher, error) { +func openZenoh() { zenohOnce.Do(func() { session, err := zenoh.Open(zenoh.NewConfigDefault(), nil) if err != nil { zenohInitError = err return } + zenohSess = session zenohPub = &zenohSessionPublisher{session: session} }) +} +func getZenohPublisher() (zenohPublisher, error) { + openZenoh() if zenohInitError != nil { return nil, zenohInitError } @@ -55,6 +61,16 @@ func getZenohPublisher() (zenohPublisher, error) { return zenohPub, nil } +// getZenohSession exposes the one session the tunnel opens, so the result +// subscriber and the action publisher share it rather than opening a second. +func getZenohSession() (zenoh.Session, error) { + openZenoh() + if zenohInitError != nil { + return zenoh.Session{}, zenohInitError + } + return zenohSess, nil +} + func PublishRobotAction(payload []byte) error { pub, err := getZenohPublisher() if err != nil { @@ -65,11 +81,28 @@ func PublishRobotAction(payload []byte) error { type Handlers struct { Logger *zap.Logger + + // Identity and pricing this tunnel publishes on the discovery endpoints. + RobotID string + ProfileID string + Network string + PayTo string + SkillCatalogPath string + + // Execution results recorded from Zenoh, keyed by action_id. + Statuses *statusStore + resultSub *zenoh.Subscriber + + // Publisher is the transport used to reach the robot. Left nil in + // production, where the process-wide Zenoh session is used; set in tests so + // the settlement-gating contract can be exercised without a live session. + Publisher zenohPublisher } func NewHandlers(logger *zap.Logger) *Handlers { return &Handlers{ - Logger: logger, + Logger: logger, + Statuses: sharedStatuses, } } @@ -114,17 +147,113 @@ func (h *Handlers) PostAction(c *gin.Context) { eventBytes, err := json.Marshal(event) if err != nil { h.Logger.Warn("failed to marshal action event", zap.Error(err)) - } else { - pub, err := getZenohPublisher() + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to encode action event", + }) + return + } + + identity := actionIdentity(body) + actionID, budget := identity.ActionID, identity.BudgetSeconds + + // Refused before anything is published. An action missing any of the four + // identity fields is one the simulator bridge will reject anyway, and one + // with no correlation id has an outcome nobody can observe — so it could + // never be settled safely. Checking after publishing would put it on the + // wire and only then say no. + if absent := identity.missing(); absent != "" || h.Statuses == nil { + if absent == "" { + absent = "result channel" + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": absent + " is required; nothing was published", + }) + return + } + + // Register interest before publishing. Registering afterwards is a race the + // simulator wins whenever it answers quickly, and losing it would look like + // a timeout. + done := h.Statuses.subscribe(actionID) + + pub := h.Publisher + if pub == nil { + pub, err = getZenohPublisher() if err != nil { h.Logger.Warn("failed to initialize zenoh publisher", zap.Error(err)) - } else if err := pub.Publish(RobotActionTopic, eventBytes); err != nil { - h.Logger.Warn("failed to publish action event", zap.Error(err)) + c.JSON(http.StatusBadGateway, gin.H{"error": "robot transport unavailable"}) + return } } + if err := pub.Publish(RobotActionTopic, eventBytes); err != nil { + h.Logger.Warn("failed to publish action event", zap.Error(err)) + // Nothing is coming for a waiter whose action never reached the robot. + h.Statuses.unsubscribe(actionID, done) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach the robot"}) + return + } - c.JSON(http.StatusOK, gin.H{ - "status": "accepted", - "timestamp": time.Now().Format(time.RFC3339), + // Accepted, not finished. The robot runs asynchronously and the terminal + // outcome is read back from GET /action/{action_id}/status, correlated by + // this id. Settlement is deliberately not part of this response: the watcher + // below runs it only if the simulator reports success, so a failed or + // timed-out episode leaves the authorization signed and unspent. + var settle SettleFunc + if value, ok := c.Get("x402_settle"); ok { + if fn, ok := value.(SettleFunc); ok { + settle = fn + } + } + go h.watchExecution(actionID, done, executionTimeout(budget), settle) + + c.JSON(http.StatusAccepted, gin.H{ + "status": "accepted", + "action_id": actionID, + "robot_id": h.RobotID, + "status_url": "/action/" + actionID + "/status", + "timestamp": time.Now().Format(time.RFC3339), }) } + +// watchExecution waits for the correlated result and decides, once, whether the +// payment is settled. It is the whole of the no-settle-on-failure guarantee: +// nothing else in this tunnel can move money. +func (h *Handlers) watchExecution(actionID string, done <-chan ActionStatus, + timeout time.Duration, settle SettleFunc) { + status, known := awaitResult(done, timeout) + + if !known { + h.Statuses.put(ActionStatus{ + ActionID: actionID, + RobotID: h.RobotID, + State: stateTimeout, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }) + h.Logger.Warn("no result before the deadline; not settling", + zap.String("action_id", actionID)) + return + } + if status.State != stateSucceeded { + h.Logger.Info("execution did not succeed; not settling", + zap.String("action_id", actionID), zap.String("state", status.State)) + return + } + if settle == nil { + h.Logger.Warn("no settlement callback for a successful action", + zap.String("action_id", actionID)) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), settlementTimeout) + defer cancel() + record, err := settle(ctx) + if err != nil { + h.Logger.Warn("settlement failed after a successful action", + zap.String("action_id", actionID), zap.Error(err)) + h.Statuses.settled(actionID, nil, err.Error()) + return + } + h.Logger.Info("settled after success", + zap.String("action_id", actionID), zap.String("tx", record.Transaction)) + h.Statuses.settled(actionID, record, "") +} diff --git a/tunnel/internal/handlers/handlers_test.go b/tunnel/internal/handlers/handlers_test.go index 08cc7126a..5ca3e863f 100644 --- a/tunnel/internal/handlers/handlers_test.go +++ b/tunnel/internal/handlers/handlers_test.go @@ -2,42 +2,364 @@ package handlers import ( "bytes" + "context" + "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" + "sync" "testing" + "time" "github.com/gin-gonic/gin" "go.uber.org/zap" ) -func TestPostAction_ValidJSON(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() +// fakePublisher stands in for the Zenoh session so the handler's contract can +// be exercised without one. +type fakePublisher struct{ published [][]byte } + +func (f *fakePublisher) Publish(_ string, payload []byte) error { + f.published = append(f.published, payload) + return nil +} + +func newTestHandlers() (*Handlers, *fakePublisher) { h := NewHandlers(zap.NewNop()) - router.POST("/action", h.PostAction) + pub := &fakePublisher{} + h.Publisher = pub + // A store per test, so one test's results cannot answer another's action. + h.Statuses = newStatusStore() + return h, pub +} + +// settleSpy stands in for the payment gate's settlement callback so a test can +// see whether money would have moved. +type settleSpy struct { + mu sync.Mutex + calls int + failWith error +} + +func (s *settleSpy) fn() SettleFunc { + return func(context.Context) (*SettlementRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + if s.failWith != nil { + return nil, s.failWith + } + return &SettlementRecord{Transaction: "0xtest", Network: "eip155:84532"}, nil + } +} - req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(`{"command":"start"}`)) +func (s *settleSpy) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func post(h *Handlers, body string, spy *settleSpy) *httptest.ResponseRecorder { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/action", func(c *gin.Context) { + if spy != nil { + c.Set("x402_settle", spy.fn()) + } + h.PostAction(c) + }) + req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(body)) res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res +} + +// settlementCalls waits briefly for the background watcher to reach its +// decision, then reports how many times it settled. +func settlementCalls(spy *settleSpy) int { + for i := 0; i < 100; i++ { + if spy.count() > 0 { + return spy.count() + } + time.Sleep(5 * time.Millisecond) + } + return spy.count() +} + +// answer delivers a simulator result once the action has been published, the +// way the Zenoh subscriber would. +func answer(h *Handlers, actionID, state string) { + go func() { + time.Sleep(20 * time.Millisecond) + h.Statuses.put(ActionStatus{ActionID: actionID, State: state}) + }() +} + +// -- the contract that keeps a failed action from being paid for ------------- + +// The x402 middleware settles after this handler returns and only when the +// response is not an error, so the status code the handler chooses *is* the +// settlement decision. These four tests are that decision. + +func TestPostActionAnswersImmediatelyWithAccepted(t *testing.T) { + h, pub := newTestHandlers() + spy := &settleSpy{} + answer(h, "act-1", stateSucceeded) + + res := post(h, `{"action_id":"act-1","robot_id":"atlas-sim-01","skill_id":"inspect_shelf","idempotency_key":"idem-1","params":{"maxDurationSec":5}}`, spy) + + if res.Code != http.StatusAccepted { + t.Fatalf("the tunnel contract answers 202 the moment an action is "+ + "accepted; got %d", res.Code) + } + if !bytes.Contains(res.Body.Bytes(), []byte("act-1")) { + t.Fatalf("the 202 must carry the action_id to correlate on, got %s", + res.Body.String()) + } + if len(pub.published) != 1 { + t.Fatalf("expected the action to reach the robot once, got %d", len(pub.published)) + } +} + +func TestSettlementFollowsSuccess(t *testing.T) { + h, _ := newTestHandlers() + spy := &settleSpy{} + answer(h, "act-2", stateSucceeded) + + post(h, `{"action_id":"act-2","robot_id":"atlas-sim-01","skill_id":"inspect_shelf","idempotency_key":"idem-2","params":{"maxDurationSec":5}}`, spy) + + if got := settlementCalls(spy); got != 1 { + t.Fatalf("a completed episode should settle exactly once, settled %d times", got) + } +} + +// The guarantee the bounty turns on: work that did not succeed is not paid for. +func TestAFailedEpisodeIsNeverSettled(t *testing.T) { + h, _ := newTestHandlers() + spy := &settleSpy{} + answer(h, "act-3", stateFailed) + + res := post(h, `{"action_id":"act-3","robot_id":"atlas-sim-01","skill_id":"inspect_shelf","idempotency_key":"idem-3","params":{"maxDurationSec":5}}`, spy) + + if res.Code != http.StatusAccepted { + t.Fatalf("acceptance is about the request, not the outcome; got %d", res.Code) + } + if got := settlementCalls(spy); got != 0 { + t.Fatalf("a failed episode was settled %d time(s)", got) + } + status, ok := h.Statuses.get("act-3") + if !ok || status.State != stateFailed { + t.Fatalf("the failure must be readable from the status endpoint, got %+v", status) + } + if status.Settled { + t.Fatalf("a failed action is reported as settled") + } +} + +func TestASilentRobotIsNeverSettled(t *testing.T) { + t.Setenv("ACTION_TIMEOUT_SECONDS", "0.2") + h, _ := newTestHandlers() + spy := &settleSpy{} + // No answer is ever delivered. + + post(h, `{"action_id":"act-4","robot_id":"atlas-sim-01","skill_id":"inspect_shelf","idempotency_key":"idem-4","params":{"maxDurationSec":5}}`, spy) + time.Sleep(400 * time.Millisecond) + + if got := spy.count(); got != 0 { + t.Fatalf("a timed-out episode was settled %d time(s)", got) + } + if status, ok := h.Statuses.get("act-4"); !ok || status.State != stateTimeout { + t.Fatalf("a timeout must be readable as a timeout, got %+v", status) + } +} + +func TestPostActionRefusesAnActionItCannotCorrelate(t *testing.T) { + h, pub := newTestHandlers() + spy := &settleSpy{} + + res := post(h, `{"command":"start"}`, spy) + + if res.Code != http.StatusBadRequest { + t.Fatalf("an action with no action_id cannot be correlated, so its outcome "+ + "is unknowable and it must not settle; got %d", res.Code) + } + // The status code alone would pass even if the action had already been put + // on the wire, which is the failure this test exists to catch: a request + // that will be refused must never reach the robot. + if len(pub.published) != 0 { + t.Fatalf("a refused action reached the robot: %d message(s) published", + len(pub.published)) + } + if spy.count() != 0 { + t.Fatalf("a refused action was settled") + } +} + +func TestPostActionRejectsInvalidJSON(t *testing.T) { + h, pub := newTestHandlers() + + res := post(h, `{"command":`, nil) + if res.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", res.Code) + } + if len(pub.published) != 0 { + t.Fatalf("an unparseable action reached the robot: %d message(s) published", + len(pub.published)) + } +} + +func TestNothingReachesTheRobotUntilTheRequestIsAccepted(t *testing.T) { + // One table for the refusals, so a new refusal path cannot be added without + // someone deciding what it does to the robot. + // The bridge refuses an envelope missing any of the four identity fields, so + // publishing one only puts a message on the wire that is going to be + // rejected at the other end. + full := `"action_id":"a","robot_id":"r","skill_id":"s","idempotency_key":"i"` + without := func(field string) string { + parts := strings.Split(full, ",") + kept := parts[:0] + for _, part := range parts { + if !strings.HasPrefix(part, `"`+field+`"`) { + kept = append(kept, part) + } + } + return "{" + strings.Join(kept, ",") + `,"params":{"maxDurationSec":5}}` + } + for name, body := range map[string]string{ + "no action_id": without("action_id"), + "no robot_id": without("robot_id"), + "no skill_id": without("skill_id"), + "no idempotency_key": without("idempotency_key"), + "empty action_id": `{"action_id":"","robot_id":"r","skill_id":"s","idempotency_key":"i"}`, + "malformed json": `{"action_id":`, + } { + t.Run(name, func(t *testing.T) { + h, pub := newTestHandlers() + res := post(h, body, nil) + if res.Code < 400 { + t.Fatalf("expected a refusal, got %d", res.Code) + } + if len(pub.published) != 0 { + t.Fatalf("refused (%d) but still published %d message(s)", + res.Code, len(pub.published)) + } + }) + } +} + +// -- discovery --------------------------------------------------------------- + +func TestActionStatusIsPendingUntilTheRobotAnswers(t *testing.T) { + gin.SetMode(gin.TestMode) + h, _ := newTestHandlers() + router := gin.New() + router.GET("/action/:action_id/status", h.GetActionStatus) + + req := httptest.NewRequest(http.MethodGet, "/action/act-unknown/status", nil) + res := httptest.NewRecorder() router.ServeHTTP(res, req) if res.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", res.Code) + t.Fatalf("expected 200, got %d", res.Code) + } + if !bytes.Contains(res.Body.Bytes(), []byte(statePending)) { + t.Fatalf("an unanswered action should read as pending, got %s", res.Body.String()) } } -func TestPostAction_InvalidJSON(t *testing.T) { +// -- identity and payee ------------------------------------------------------ + +// The wiki asks that a robot's identity bind to the payee wallet. The +// authenticating handshake between a robot and the relay belongs to the shared +// tunnel and gateway, but the half this tunnel owns is checkable: the identity +// it answers for and the address it is paid to come from one configuration and +// are advertised together, so a caller can see which wallet the robot it is +// talking to gets paid at before paying anything. +func TestTheAdvertisedPayeeIsTheConfiguredOne(t *testing.T) { gin.SetMode(gin.TestMode) - router := gin.New() - h := NewHandlers(zap.NewNop()) - router.POST("/action", h.PostAction) + h, _ := newTestHandlers() + h.RobotID = "atlas-sim-01" + h.PayTo = "0x7b9163254A21b249a0D3E34300fC81BB0A43C3e8" + h.Network = "eip155:84532" - req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(`{"command":`)) + router := gin.New() + router.GET("/robot", h.GetRobotProfile) + req := httptest.NewRequest(http.MethodGet, "/robot", nil) res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", res.Code) + } + var profile struct { + RobotID string `json:"robot_id"` + PayTo string `json:"pay_to"` + Network string `json:"network"` + } + if err := json.Unmarshal(res.Body.Bytes(), &profile); err != nil { + t.Fatalf("unreadable robot profile: %v", err) + } + if profile.RobotID != h.RobotID { + t.Fatalf("advertised robot_id %q, configured %q", profile.RobotID, h.RobotID) + } + if profile.PayTo != h.PayTo { + t.Fatalf("advertised pay_to %q, configured %q — a caller paying this robot "+ + "would be told the wrong wallet", profile.PayTo, h.PayTo) + } + if profile.Network != h.Network { + t.Fatalf("advertised network %q, configured %q", profile.Network, h.Network) + } +} + +// A robot that has not been told who it is paid to must not advertise an empty +// payee as though it were an address. +func TestAnUnconfiguredPayeeIsNotAdvertisedAsAnAddress(t *testing.T) { + gin.SetMode(gin.TestMode) + h, _ := newTestHandlers() + h.RobotID = "atlas-sim-01" + h.PayTo = "" + + router := gin.New() + router.GET("/robot", h.GetRobotProfile) + req := httptest.NewRequest(http.MethodGet, "/robot", nil) + res := httptest.NewRecorder() router.ServeHTTP(res, req) - if res.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", res.Code) + var profile struct { + PayTo string `json:"pay_to"` + } + _ = json.Unmarshal(res.Body.Bytes(), &profile) + if profile.PayTo != "" { + t.Fatalf("an unconfigured payee was advertised as %q", profile.PayTo) + } +} + +// failingPublisher stands in for a transport that is down. +type failingPublisher struct{ calls int } + +func (f *failingPublisher) Publish(string, []byte) error { + f.calls++ + return errors.New("transport down") +} + +// A waiter registered before a publish that then fails would otherwise sit in +// the store for ever, since nothing is coming to answer it. +func TestAFailedPublishLeavesNoWaiterBehind(t *testing.T) { + h, _ := newTestHandlers() + h.Publisher = &failingPublisher{} + + res := post(h, `{"action_id":"act-9","robot_id":"r","skill_id":"s",`+ + `"idempotency_key":"i","params":{"maxDurationSec":5}}`, nil) + + if res.Code != http.StatusBadGateway { + t.Fatalf("a transport failure should answer 502, got %d", res.Code) + } + h.Statuses.mu.RLock() + remaining := len(h.Statuses.waiters) + h.Statuses.mu.RUnlock() + if remaining != 0 { + t.Fatalf("%d waiter(s) left for an action that never reached the robot", remaining) } }