Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/go2-simulation-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: go2-simulation-tests

on:
push:
paths:
- "simulation/go2/**"
- "simulation/pybullet/**"
- "simulation/setup.sh"
- "registry/vendors/unitree/**"
- ".github/workflows/go2-simulation-tests.yml"
pull_request:
paths:
- "simulation/go2/**"
- "simulation/pybullet/**"
- "simulation/setup.sh"
- "registry/vendors/unitree/**"
- ".github/workflows/go2-simulation-tests.yml"

jobs:
mujoco-pybullet:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install mujoco numpy pybullet cryptography eclipse-zenoh
- name: Fetch Go2 model assets
run: cd simulation && bash setup.sh
- name: Skill acceptance (wave / sit / stand / stop / bow / nod / turn / hold)
run: cd simulation/go2 && python3 test_go2_control.py
- name: Payment gate (402 / 409 / settle-only-on-success)
run: cd simulation/go2 && python3 test_payment_gate.py
- name: Result semantics over peer-mode Zenoh (success + every error path)
run: cd simulation/go2 && python3 test_result_semantics.py
- name: End-to-end paid action over peer-mode Zenoh
run: cd simulation/go2 && python3 test_link.py
- name: Sim-to-sim (MuJoCo poses vs PyBullet kinematic URDF)
run: cd simulation/pybullet && python3 test_sim2sim_go2.py
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,53 @@ Set the payee address (and any overrides) in `tunnel/config.json`:
{
"robot_id": "my-robot",
"evm_payee_address": "0xYourAddress",
"price": "$0.002",
"price": "0.002",
"network": "eip155:84532"
}
```

| Field | Required | Default | Description |
|--------------------------|---------------|-----------------|------------------------------------------------------------|
| `robot_id` | No | random UUID | Unique robot identifier |
| `evm_payee_address` | **Yes** | — | EVM address to receive x402 payments |
| `price` | No | `0.001` | Price per action, in whole token units |
| `network` | No | `eip155:8453` | CAIP-2 network ID (e.g. `eip155:84532`) |
| `token_address` | No | network default | ERC-20 the price is charged in |
| `token_name` | For `eip3009` | — | Token's `name()`, forms the EIP-712 domain the payer signs |
| `token_version` | No | `1` | Token version used in the EIP-712 domain |
| `token_decimals` | No | `6` | Token decimals, used to convert `price` to atomic units |
| `token_transfer_method` | No | `eip3009` | `eip3009` or `permit2` — how the payment settles |
| `token_supports_eip2612` | No | `false` | `permit2` only: payer signs a permit instead of approving |

`price` is a decimal amount in whole units of the payment token, converted to atomic units using
`token_decimals` — with `token_decimals: 18`, `"1"` charges `1000000000000000000`. A leading `$`
is optional and carries no meaning; it only reads as dollars when the token is a stablecoin.

### Custom payment token

For well-known chains x402 already knows which stablecoin to use (USDC on Base, and so on), so
`token_address` can be omitted. On any other chain there is no default and requests fail with
`no default stablecoin configured for network <network>` — set `token_address` to register the
token as that network's default asset at startup. See
[`tunnel/config.example.json`](tunnel/config.example.json).

`token_transfer_method` decides how the facilitator moves the tokens:

- **`eip3009`** (default) — the payer signs a `TransferWithAuthorization` message and the
facilitator calls `transferWithAuthorization` on the token. **Only works if the token actually
implements EIP-3009** (USDC and friends). Against a plain ERC-20 the signature is produced
happily and settlement then reverts. `token_name`/`token_version` must match the token's own
EIP-712 domain (its `name()`, not its symbol) or the signature will not verify.
- **`permit2`** — the payer signs a Permit2 witness and the facilitator settles through the x402
exact Permit2 proxy. Works with **any** plain ERC-20, at the cost of a one-time
`approve(0x000000000022D473030F116dDEE9F6B43aC78BA3, …)` from each payer. The signed domain is
Permit2's own, so `token_name`/`token_version` are neither required nor advertised. Set
`token_supports_eip2612: true` only if the token has `permit()`, which lets the payer skip the
approval transaction.

The facilitator has to support the chosen method too — it is the one that submits the settlement
transaction.

Build and run from the repo root (the `Makefile` operates inside `tunnel/`):

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Unitree Go2 — robot action profile (simulator)

Profile ID: `unitree.go2.mujoco-pybullet-sim.v1`

Scope: **simulator-only**. A paid RoboPay action arriving on the Zenoh topic
`robot/tunnel/action` starts a Go2 skill episode on the official MuJoCo model
(`google-deepmind/mujoco_menagerie` `unitree_go2`). Eight skills are
available, each driven by a joint-space trajectory controller — never by a
recorded animation or a built-in demo motion.

| file | what it describes |
|---|---|
| `robot.profile.yaml` | robot identity, Zenoh runtime, action/result topics |
| `skills.yaml` | the 8 skills, their params and limits |
| `functions.yaml` | agent-facing REST contract (`/action`, 402 + `PAYMENT-REQUIRED`) |
| `payment-policy.yaml` | x402 pricing per skill, settle-on-success rule |
| `execution-mapping.yaml` | how each skill maps to the simulator runtime + metrics |
| `examples/` | sample paid action envelope |
| `tests/` | skill-contract cases (success, replay, unknown skill, tampering, unpaid) |
| `validation-report.md` | full validation evidence, sim-to-sim results, limitations |

See `simulation/README.md` for setup, tests, wire contract and troubleshooting.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Validation report — unitree.go2.mujoco-pybullet-sim.v1

OS: Windows 11; MuJoCo tests also on ubuntu-latest via CI
ROS2: not used (simulator-only; Zenoh consumed directly, see simulation/README.md)
Zenoh: eclipse-zenoh 1.x (Python), peer mode, localhost
Simulators: MuJoCo (google-deepmind/mujoco_menagerie unitree_go2)
and PyBullet (deterministic kinematic URDF generated from the
*same* go2.xml)

## Validated skills

- [x] wave
- [x] sit
- [x] stand
- [x] stop (safe stop)
- [x] bow
- [x] nod
- [x] turn_to_face
- [x] hold

## Skill acceptance (simulation/go2/test_go2_control.py)

Home stance height measured after settling: **0.283 m** (the controller
re-measures its own resting height, so the acceptance tests compare against
the robot's own stance rather than a hardcoded constant).

| skill | observed metric | threshold | result |
|---|---|---|---|
| wave | pawLift 0.167 m, body stays at 0.283 m | pawLift > 0.15 | pass |
| sit | sitDepth 0.145 m | > 0.10 | pass |
| stand | returns to home stance | ~ home | pass |
| stop | returns to home stance | |bodyZ - home| < 0.02 | pass |
| bow | bowPitchDeg 18.8 deg | > 10 | pass |
| nod | nodDepth 0.040 m | > 0.02 | pass |
| turn_to_face | yawed 17.2 deg toward heading 30, residual 12.9 deg | > 4 | pass |
| hold | stance held at 0.283 m | stable | pass |
| unknown skill | error result UNKNOWN_SKILL | error | pass |

Every successful skill returns the body to the home stance height afterwards
(|bodyZ - 0.283| < 0.02), so paid actions can run back to back.

`turn_to_face` reports the achieved yaw and the remaining heading error
honestly: a partial turn is never faked as a complete one (the result message
states "Partial turn: X deg short of heading" when the residual exceeds 2
deg). The yaw is produced by a differential hip-abduction shuffle (front pair
vs hind pair) driven by a proportional servo; the pose stays inside the
static-stability polygon, so the body stays level and no external torque is
applied to the torso.

## Validation results

- [x] Skill catalog returns expected skills (robopay_link.py startup log)
- [x] Unpaid request returns 402 (simulation/go2/test_payment_gate.py)
- [x] Expired / forged receipts rejected 402 (test_payment_gate.py)
- [x] Tampered params hash left to the validator -> INVALID_PARAMS
(test_result_semantics.py)
- [x] Paid request returns 200 accepted (tunnel PostAction; test_link.py)
- [x] Duplicate idempotencyKey does not execute twice (test_result_semantics.py)
- [x] Zenoh message received (test_link.py: tunnel round-trip + action delivery)
- [x] Robot bridge received action (robopay_link.py logs with actionId)
- [x] Robot movement observed (MuJoCo/PyBullet episodes; simulation/docs/go2.gif)
- [x] Structured result on robot/tunnel/result correlated by actionId
- [x] Safe stop: `stop` halts motion and returns the robot to the stable home
stance (fail-safe skill; see simulation/go2/test_go2_control.py)
- [x] Failure paths return {"status": "error"} and never settle
(UNPAID / INVALID_PARAMS / UNKNOWN_SKILL / WRONG_ROBOT / DUPLICATE /
tampered paramsHash — test_result_semantics.py + test_payment_gate.py)

## Sim-to-sim (simulation/pybullet/test_sim2sim_go2.py)

The same skill joint configurations are recomputed in MuJoCo and PyBullet at
each skill's salient pose (wave peak lift, sit deepest crouch, bow max pitch,
nod max dip, turn end, home). MuJoCo loads the official menagerie
`unitree_go2/scene.xml`; PyBullet loads a kinematic URDF
(`go2_simple_kin.urdf`) that `make_go2_kin_urdf.py` generates deterministically
from that *same* `go2.xml` — PyBullet cannot parse the menagerie MJCF 3.x
directly, so the conversion is committed and reproducible rather than
hand-rolled. Foot-sphere centres agree to well under 1 cm across all poses and
all four feet (simulation/pybullet/go2_sim2sim_report.json). Both simulators
therefore run the same kinematics for every skill.

## Evidence

Commands:

cd simulation && ./setup.sh
cd simulation/go2
python3 test_go2_control.py
python3 test_payment_gate.py
python3 test_result_semantics.py
python3 test_link.py
cd ../pybullet
python3 test_sim2sim_go2.py

Logs: each test prints its checks as JSON and PASS/FAIL.

Known limitations: simulator-only profile — payment settlement is simulated.
The x402 gate (402/409, signed receipts, settle-only-on-success) is exercised
against the same facilitator the robot link trusts; no on-chain settlement
happens. The tunnel binary in this repo is not run on Windows; the Python
payment gate reimplements the exact decisions the tunnel's x402 middleware
makes before actuation, and the wire contract is exercised through peer-mode
Zenoh exactly as the tunnel would publish it (see simulation/README.md).
turn_to_face reports the achieved yaw and remaining error honestly (a partial
turn is not faked as a complete one); the heading is reached via a
static-stability hip-abduction shuffle, so no external force is applied to
the torso.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"actionId": "act_example",
"robotId": "test-robot",
"skillId": "turn_to_face",
"params": {
"headingDeg": 30.0
},
"paramsHash": "a2befd60f83a5ddfd881f41d10d000f7a176363bb95aa74c1c23a5d5365e1fa9",
"idempotencyKey": "example-turn-001",
"payment": {
"scheme": "exact",
"network": "eip155:84532",
"payer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"amountUSDC": "0.002",
"txHash": "0xabababababababababababababababababababababababababababababababab",
"simulated": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
schemaVersion: execution-mapping.v1
transport:
type: zenoh
topic: robot/tunnel/action
resultTopic: robot/tunnel/result
mappings:
wave:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space trajectory on the official model, body-weight
compensation xfrc while the front-right paw is airborne
goal: lift and lower the front-right paw
metrics: [pawLift, bodyZ, bodyRollDeg, bodyPitchDeg, bodyYawDeg]
sit:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space crouch to a static sit posture, then recovery
goal: crouch and return to stance
metrics: [sitDepth, bodyZ]
stand:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space recovery to the home stance
goal: return to the home standing height
metrics: [standHeight]
stop:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space safe-stop to the home stance (short timeline)
goal: halt motion and return to the stable home stance
metrics: [stopHeight, bodyZ]
bow:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space play bow (front legs flex, hind stays up)
goal: pitch the torso forward and recover
metrics: [bowPitchDeg, bodyPitchDeg, bodyZ]
nod:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: joint-space full-body bob
goal: a measured dip of the body and recovery
metrics: [nodDepth, bodyZ]
turn_to_face:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: single-phase proportional yaw servo on the hip joints
(static-stability shuffle), target = "$params.headingDeg"
goal: yaw the body toward the requested heading
metrics: [achievedYawDeg, finalHeadingErrorDeg, bodyYawDeg, bodyZ]
hold:
output: mujoco-or-pybullet episode (simulation/go2/robopay_link.py)
controller: hold the stance joints for "$params.seconds"
goal: hold the current stance
metrics: [bodyZ]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
schemaVersion: agent-functions.v1
functions:
- name: request_robot_action
method: POST
url: /action
body:
actionId: string
robotId: string
skillId: string
params: object
paramsHash: sha256 of canonical JSON params
idempotencyKey: 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
schemaVersion: payment-policy.v1
provider: x402
network: eip155:84532
policies:
- skillId: wave
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: sit
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: stand
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: stop
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: bow
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: nod
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: turn_to_face
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
- skillId: hold
required: true
priceUSDC: "0.002"
paymentHeader: PAYMENT-SIGNATURE
settlement:
settleOnStatus: success
rule: >-
The relay settles only on results with status "success". Every error
result (DUPLICATE, UNKNOWN_SKILL, INVALID_PARAMS, WRONG_ROBOT,
REJECTED_PAYMENT, UNPAID, ACTION_FAILED) must not settle.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
schemaVersion: robot-profile.v1
vendor: unitree
robotModel: go2
robotType: mujoco-pybullet-sim
profileId: unitree.go2.mujoco-pybullet-sim.v1
profileVersion: 1.0.0
runtime:
transport: zenoh
actionTopic: robot/tunnel/action
resultTopic: robot/tunnel/result
bridge: tunnel (this repo, Go) + simulation/go2/robopay_link.py
simulators: [mujoco, pybullet]
maintainers:
- github: EslaM-X
status: experimental
scope: simulator-only
Loading