Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish-openclaw.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ on:
required: false
type: string
foundry_version:
description: 'Foundry nightly tag (e.g. nightly-abc123...). Defaults to pinned version.'
description: 'Foundry version tag (e.g. v1.5.1). Defaults to pinned version.'
required: false
type: string

Expand Down
44 changes: 41 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ Skills are delivered by writing directly to the host filesystem at `$DATA_DIR/op

**Advantages over ConfigMap approach**: No 1MB size limit, works before pod readiness, survives pod restarts, supports binary files and scripts.

### Default Skills (20 skills)
### Default Skills (21 skills)

The stack ships 20 embedded skills organized into categories. All are installed automatically on first deploy.

Expand All @@ -811,6 +811,7 @@ The stack ships 20 embedded skills organized into categories. All are installed
| Skill | Contents | Purpose |
|-------|----------|---------|
| `ethereum-networks` | `SKILL.md`, `scripts/rpc.sh`, `scripts/rpc.py`, `references/erc20-methods.md`, `references/common-contracts.md` | Read-only Ethereum queries via cast/eRPC — blocks, balances, contract reads, ERC-20 lookups, ENS resolution |
| `ethereum-local-wallet` | `SKILL.md`, `scripts/signer.py`, `references/remote-signer-api.md` | Sign and send Ethereum transactions via the per-agent remote-signer service |
| `obol-stack` | `SKILL.md`, `scripts/kube.py` | Kubernetes cluster diagnostics — pods, logs, events, deployments via ServiceAccount API |
| `distributed-validators` | `SKILL.md`, `references/api-examples.md` | Obol DVT cluster monitoring, operator audit, exit coordination via Obol API |

Expand Down Expand Up @@ -875,7 +876,44 @@ obol openclaw skills sync # re-inject embedded defaults to vol
obol openclaw skills sync --from <dir> # push custom skills from local directory
```

### Key Source Files
### Ethereum Local Wallet (Remote-Signer)

Each OpenClaw instance is provisioned with an Ethereum signing wallet during `obol openclaw onboard`. The wallet is backed by a remote-signer service (Rust-based REST API) deployed in the same namespace.

**Architecture**:
```
Namespace: openclaw-<id>
OpenClaw Pod ──HTTP:9000──> remote-signer Pod
(signer.py skill) /data/keystores/<uuid>.json (V3)
└── eth_sendRawTransaction ──> eRPC (:4000/rpc)
```

**Key generation**: secp256k1 via `crypto/rand` + `github.com/decred/dcrd/dcrec/secp256k1/v4`, encrypted to Web3 Secret Storage V3 format (scrypt + AES-128-CTR).

**Provisioning flow**:
1. `GenerateWallet()` creates key + V3 keystore + random password
2. Keystore written to host PVC path: `$DATA_DIR/openclaw-<id>/remote-signer-keystores/`
3. Password stored in `values-remote-signer.yaml` for the Helm chart
4. `generateHelmfile()` includes both `obol/openclaw` and `obol/remote-signer` releases
5. After helmfile sync, `applyWalletMetadataConfigMap()` creates a `wallet-metadata` ConfigMap for the frontend

**Remote-signer API** (ClusterIP, port 9000):
- `GET /api/v1/keys` — list signing addresses
- `POST /api/v1/sign/{address}/transaction` — sign EIP-1559 tx
- `POST /api/v1/sign/{address}/message` — sign EIP-191 message
- `POST /api/v1/sign/{address}/typed-data` — sign EIP-712 typed data
- `POST /api/v1/sign/{address}/hash` — sign raw hash

**Key source files**:

| File | Role |
|------|------|
| `internal/openclaw/wallet.go` | Key generation, V3 keystore encryption, provisioning, ConfigMap creation |
| `internal/openclaw/wallet_test.go` | Unit tests for key gen, encrypt/decrypt round-trip, address derivation |
| `internal/embed/skills/ethereum-local-wallet/` | Signing skill (SKILL.md, scripts/signer.py, references/) |

### Key Source Files (Skills)

| File | Role |
|------|------|
Expand Down Expand Up @@ -1143,7 +1181,7 @@ obol network delete ethereum-<generated-name> --force
- `aztec/helmfile.yaml.gotmpl`
- `internal/embed/defaults/` - Default stack resources
- `internal/embed/infrastructure/` - Infrastructure resources (llmspy, Traefik)
- `internal/embed/skills/` - Default OpenClaw skills (20 skills) embedded in obol binary
- `internal/embed/skills/` - Default OpenClaw skills (21 skills) embedded in obol binary

**Skills system**:
- `internal/openclaw/resolve.go` - Smart instance resolution (0/1/2+ instances)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,14 @@ When only one OpenClaw instance is installed, the instance ID is optional — it

### Skills

OpenClaw ships with 20 embedded skills that are installed automatically on first deploy. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns.
OpenClaw ships with 21 embedded skills that are installed automatically on first deploy. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns.

#### Infrastructure

| Skill | Purpose |
|-------|---------|
| `ethereum-networks` | Read-only Ethereum queries via cast — blocks, balances, contract reads, ERC-20, ENS |
| `ethereum-local-wallet` | Sign and send Ethereum transactions via the per-agent remote-signer |
| `obol-stack` | Kubernetes cluster diagnostics — pods, logs, events, deployments |
| `distributed-validators` | Obol DVT cluster monitoring, operator audit, exit coordination |

Expand Down
2 changes: 1 addition & 1 deletion docker/openclaw/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# after the base image is built from upstream source.
#
# Usage (CI):
# docker build --build-arg BASE_TAG=v2026.2.15 --build-arg FOUNDRY_TAG=nightly-... \
# docker build --build-arg BASE_TAG=v2026.2.23 --build-arg FOUNDRY_TAG=v1.5.1 \
# -f docker/openclaw/Dockerfile .
#
ARG FOUNDRY_TAG
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ module github.com/ObolNetwork/obol-stack
go 1.25.1

require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
github.com/google/uuid v1.6.0
github.com/mark3labs/x402-go v0.13.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.45.0
gopkg.in/yaml.v3 v3.0.1
)

Expand All @@ -19,7 +22,6 @@ require (
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
Expand All @@ -37,7 +39,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
Expand Down
14 changes: 12 additions & 2 deletions internal/embed/embed_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestGetEmbeddedSkillNames(t *testing.T) {
// Core skills that must always be present
coreSkills := []string{
"addresses", "building-blocks", "concepts", "distributed-validators",
"ethereum-networks", "frontend-playbook", "frontend-ux", "gas",
"ethereum-networks", "ethereum-local-wallet", "frontend-playbook", "frontend-ux", "gas",
"indexing", "l2s", "obol-stack", "orchestration", "qa", "security",
"ship", "standards", "testing", "tools", "wallets", "why",
}
Expand Down Expand Up @@ -45,7 +45,7 @@ func TestCopySkills(t *testing.T) {
}

// Every skill must have a SKILL.md
skills := []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"}
skills := []string{"distributed-validators", "ethereum-networks", "ethereum-local-wallet", "obol-stack", "addresses", "wallets"}
for _, skill := range skills {
skillMD := filepath.Join(destDir, skill, "SKILL.md")
info, err := os.Stat(skillMD)
Expand All @@ -69,6 +69,16 @@ func TestCopySkills(t *testing.T) {
}
}

// ethereum-local-wallet must have scripts/signer.py and references/
for _, sub := range []string{
"ethereum-local-wallet/scripts/signer.py",
"ethereum-local-wallet/references/remote-signer-api.md",
} {
if _, err := os.Stat(filepath.Join(destDir, sub)); err != nil {
t.Errorf("missing %s: %v", sub, err)
}
}

// obol-stack must have scripts/kube.py
if _, err := os.Stat(filepath.Join(destDir, "obol-stack", "scripts", "kube.py")); err != nil {
t.Errorf("missing obol-stack/scripts/kube.py: %v", err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ image:

repository: obolnetwork/obol-stack-front-end
pullPolicy: IfNotPresent
tag: "v0.1.8"
tag: "v0.1.9"

service:
type: ClusterIP
Expand Down
110 changes: 110 additions & 0 deletions internal/embed/skills/ethereum-local-wallet/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: ethereum-local-wallet
description: "Sign and send Ethereum transactions via the local remote-signer. Use when asked to send ETH, sign messages, approve tokens, or interact with smart contracts that modify state. All signing goes through the in-cluster remote-signer; agents never touch private key material."
metadata: { "openclaw": { "emoji": "💳", "requires": { "bins": ["python3"] } } }
---

# Ethereum Wallet

Sign and send Ethereum transactions through the local remote-signer service. The agent never accesses private keys directly — all signing is done via HTTP API calls to the remote-signer, which holds encrypted keystores.

## When to Use

- Sending ETH or ERC-20 tokens
- Signing messages (EIP-191) or typed data (EIP-712)
- Interacting with smart contracts that modify state (write operations)
- Approving token allowances
- Any operation that requires a transaction signature

## When NOT to Use

- **Read-only queries** — use the `ethereum-networks` skill instead (`rpc.sh`)
- **Key generation** — keys are managed by the `obol` CLI, not by the agent
- **Cross-chain bridges** — not supported in this skill

## Quick Start

```bash
# List your signing addresses
python3 scripts/signer.py accounts

# Check remote-signer health
python3 scripts/signer.py health

# Sign a message
python3 scripts/signer.py sign-msg 0xYOUR_ADDRESS "Hello, Ethereum!"

# Sign a transaction (returns raw signed tx hex, does NOT broadcast)
python3 scripts/signer.py sign-tx \
--from 0xYOUR_ADDRESS --to 0xRECIPIENT \
--value 1000000000000000000 --network mainnet

# Sign AND broadcast a transaction via eRPC
python3 scripts/signer.py send-tx \
--from 0xYOUR_ADDRESS --to 0xRECIPIENT \
--value 1000000000000000000 --network mainnet
```

## Available Commands

| Command | Description |
|---------|-------------|
| `accounts` | List all signing addresses from the remote-signer |
| `health` | Check remote-signer `/healthz` endpoint |
| `sign <address> <hex-data>` | Sign a raw 32-byte hash |
| `sign-msg <address> <message>` | Sign a message with EIP-191 prefix |
| `sign-tx --from --to [--value] [--data] [--gas] [--nonce] [--network]` | Sign an EIP-1559 transaction |
| `send-tx --from --to [--value] [--data] [--network]` | Sign AND broadcast a transaction via eRPC |
| `sign-typed <address> <json>` | Sign EIP-712 typed data |

## Transaction Submission Flow

```
1. Agent decides to send a transaction
2. signer.py: GET /api/v1/keys → list available signing addresses
3. signer.py: eth_getTransactionCount → eRPC (nonce)
4. signer.py: eth_gasPrice + eth_maxPriorityFeePerGas → eRPC (fees)
5. signer.py: eth_estimateGas → eRPC (gas limit)
6. signer.py: POST /api/v1/sign/{address}/transaction → remote-signer
→ Returns RLP-encoded signed transaction hex
7. signer.py: eth_sendRawTransaction → eRPC (broadcast)
8. signer.py: eth_getTransactionReceipt → eRPC (confirmation)
```

## Multi-Network Support

The same signing key works on any EVM chain. Specify the network via `--network`:

```bash
python3 scripts/signer.py send-tx --network hoodi \
--from 0x... --to 0x... --value 1000000000000000000
```

Supported networks: `mainnet`, `hoodi`, `sepolia` (depends on eRPC configuration).

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `REMOTE_SIGNER_URL` | `http://remote-signer:9000` | Remote-signer REST API base URL |
| `ERPC_URL` | `http://erpc.erpc.svc.cluster.local:4000/rpc` | eRPC gateway for RPC calls |
| `ERPC_NETWORK` | `mainnet` | Default network for RPC routing |

## Security Model

- **Agent never touches keys**: All signing via HTTP to the remote-signer
- **ClusterIP isolation**: Remote-signer is only reachable within the cluster
- **Encrypted keystores**: V3 Web3 Secret Storage format with scrypt KDF
- **Values in wei**: No automatic unit conversion — always specify amounts in wei

## Important Notes

- Always **confirm with the user** before sending transactions
- Values are in **wei** (1 ETH = 1000000000000000000 wei)
- Use `ethereum-networks` skill (`rpc.sh balance <address>`) to check balances before sending
- The `send-tx` command will broadcast the transaction immediately after signing

## See Also

- `ethereum-networks` — read-only blockchain queries via eRPC
- `references/remote-signer-api.md` — full remote-signer REST API reference
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Remote-Signer REST API Reference

Base URL: `$REMOTE_SIGNER_URL` (default: `http://remote-signer:9000`)

## Health

| Method | Path | Response |
|--------|------|----------|
| GET | `/healthz` | `{"status": "ok"}` |
| GET | `/upcheck` | `OK` (text) |

## Key Management

| Method | Path | Response |
|--------|------|----------|
| GET | `/api/v1/keys` | `{"keys": ["0xABC...", "0xDEF..."]}` |
| POST | `/api/v1/keystores/reload` | `{"keys_loaded": N}` |

## Signing

All signing endpoints: `POST /api/v1/sign/{address}/{operation}`

### Sign Transaction (EIP-1559)

```
POST /api/v1/sign/0x{ADDRESS}/transaction
Content-Type: application/json

{
"chain_id": 1,
"to": "0x...",
"nonce": 42,
"gas_limit": 21000,
"max_fee_per_gas": 30000000000,
"max_priority_fee_per_gas": 1000000000,
"value": "0x0",
"data": "0x"
}

→ 200: {"signed_transaction": "0x02f8..."}
```

Gas fields accept JSON numbers or hex strings.

### Sign Message (EIP-191)

```
POST /api/v1/sign/0x{ADDRESS}/message
Content-Type: application/json

{"message": "Hello, Ethereum!"}

→ 200: {"signature": "0x..."}
```

Supports plain text or `0x`-prefixed hex messages.

### Sign Typed Data (EIP-712)

```
POST /api/v1/sign/0x{ADDRESS}/typed-data
Content-Type: application/json

{
"types": {"EIP712Domain": [...], "Mail": [...]},
"primaryType": "Mail",
"domain": {"name": "Example", "chainId": 1},
"message": {"contents": "Hello"}
}

→ 200: {"signature": "0x..."}
```

### Sign Raw Hash

```
POST /api/v1/sign/0x{ADDRESS}/hash
Content-Type: application/json

{"hash": "0x0000...0001"}

→ 200: {"signature": "0x..."}
```

## Error Responses

```json
{"error": "...", "code": "SIGNER_NOT_FOUND", "address": "0x..."}
```

| Status | Code | Meaning |
|--------|------|---------|
| 400 | `BAD_REQUEST` | Malformed request body |
| 404 | `SIGNER_NOT_FOUND` | Address not loaded |
| 422 | `MISSING_FIELD` | Missing required field |
| 500 | `SIGNING_ERROR` | Internal signing failure |

## Signature Format

65 bytes: `r (32) || s (32) || v (1)`, hex-encoded with `0x` prefix (132 chars total).
Loading
Loading