Skip to content
Draft
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
43 changes: 43 additions & 0 deletions internal/embed/skills/obol-claim-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: obol-claim-test
description: "TEST skill — trigger an OBOL merkle airdrop claim for a paying buyer against a test MerkleDistributorWithDeadline on mainnet (address set in config.json / OBOL_CLAIM_DISTRIBUTOR after deploy). Given an Ethereum address in the test tree, verify it against the live on-chain root, then send the claim transaction (tokens go to that address; the agent only pays gas). This is the ONLY transaction this skill can send."
metadata: { "openclaw": { "emoji": "🪂", "requires": { "bins": ["python3"] } } }
---

# OBOL Claim (TEST distributor)

End-to-end test variant of `obol-incentives-claim`, pinned to a small test
deployment so the full paid-claim loop can be proven on mainnet for a few OBOL.

- **Distributor:** set after deploy via `config.json` `distributor` (or the
`OBOL_CLAIM_DISTRIBUTOR` env). Claim chain is OBOL on Ethereum **mainnet**.
- **Tree:** the operator's agent wallets, **100 OBOL each** (root
`0x413b9960…`, 200 OBOL total). Regenerate from
`merkle-distributor/testclaim/` if the wallet set changes.
- Distributor/network resolve from `config.json` (env wins); everything else is
identical to `obol-incentives-claim` (see that skill's SKILL.md for the full
security model). Note: the x402 **payment** for using the agent (e.g. 0.01
USDC on Base) is independent of the claim chain (OBOL on mainnet).

The only transaction this skill can produce is
`claim(uint256,address,uint256,bytes32[])` to the pinned distributor, `value=0`.
Every send is gated on: bundled root == live on-chain `merkleRoot()` AND a
recomputed proof AND `isClaimed==false` AND a successful `eth_call` simulation.

## Commands

```bash
python3 scripts/claim.py contract # root match, deadline, token, claimers
python3 scripts/claim.py check <address> # read-only eligibility (no tx)
python3 scripts/claim.py claim <address> # full guarded claim
python3 scripts/claim.py self-test # offline integrity + source-safety audit
```

## Environment (optional — config.json provides defaults)

| Variable | Default | Description |
|----------|---------|-------------|
| `OBOL_CLAIM_DISTRIBUTOR` | `config.json` value | Override the distributor address. |
| `OBOL_CLAIM_NETWORK` | `mainnet` | eRPC network alias. |
| `OBOL_CLAIM_FROM` | first remote-signer key | Agent wallet that pays gas / signs. |
| `ERPC_URL` / `REMOTE_SIGNER_URL` / `REMOTE_SIGNER_TOKEN` | in-cluster defaults | Injected in the agent pod. |
5 changes: 5 additions & 0 deletions internal/embed/skills/obol-claim-test/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"distributor": "",
"network": "mainnet",
"note": "Set distributor to the deployed TEST MerkleDistributorWithDeadline address (or pass OBOL_CLAIM_DISTRIBUTOR env). Test tree = operator agent wallets, 100 OBOL each, root 0x413b9960..."
}
20 changes: 20 additions & 0 deletions internal/embed/skills/obol-claim-test/merkle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"merkleRoot": "0x413b996023d4b7e0a1cb9efb47df45207ba5b4dbf8144a979888f5d63fa302e6",
"totalAmount": "200000000000000000000",
"claims": {
"0x7DC59644FBAB84ac213453af4d2dAaB4D2f03a54": {
"index": 0,
"amount": "100000000000000000000",
"proof": [
"0xab1afc2b27f69924f142aab302bbc4fd95c8aebaf0fe437b7cbca371117da247"
]
},
"0xD0391EeDc3268F3deeF1F05fff5D7aEf82F64cCF": {
"index": 1,
"amount": "100000000000000000000",
"proof": [
"0x35e801f95e645014b9d3ae01570bf17c9420b35621fee2a3c3266bb1297618ba"
]
}
}
}
64 changes: 64 additions & 0 deletions internal/embed/skills/obol-claim-test/references/merkle-recipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Merkle leaf & proof recipe (must match the deployed contract)

The distributor is `MerkleDistributorWithDeadline` (Solidity 0.8.17, OpenZeppelin
`MerkleProof`). Its on-chain verification is:

```solidity
bytes32 node = keccak256(bytes.concat(keccak256(abi.encode(index, account, amount))));
if (!MerkleProof.verify(merkleProof, merkleRoot, node)) revert InvalidProof();
```

So the leaf and proof MUST be built the OpenZeppelin `StandardMerkleTree` way —
**not** the Uniswap `parse-balance-map` way. The two differ on three points; get
any one wrong and the recomputed root won't match the chain:

| Aspect | OpenZeppelin (this contract) | Uniswap (wrong here) |
|--------|------------------------------|----------------------|
| Hash depth | **double**: `keccak(keccak(...))` | single: `keccak(...)` |
| Encoding | **`abi.encode`** — address left-padded to 32 bytes (96 bytes total) | `abi.encodePacked` — address is 20 bytes (84 bytes total) |
| Leaf set | generated with `sort_leaves=False`; proofs come from `merkle.json` | leaves sorted ascending |

Leaf, in this skill (`claim.py:leaf_hash`):

```
enc = uint256(index) # 32 bytes, big-endian
+ 0x00 * 12 + address(20) # abi.encode pads address to 32 bytes
+ uint256(amount) # 32 bytes, big-endian
leaf = keccak256(keccak256(enc))
```

Proof walk (`claim.py:proof_root`) is OpenZeppelin's commutative `_hashPair`:

```
combine(a, b) = keccak256(a + b) if a <= b
keccak256(b + a) otherwise
```

Apply `combine` left-to-right across the proof; the result must equal the
on-chain `merkleRoot()`.

## Where the tree comes from

`merkle.json` is produced by `../merkle-distributor/scripts/merkle_cli.py`:

```python
entries.sort(key=lambda e: e[0].lower()) # sort by lowercased address
values = [[i, addr, amt] for i, (addr, amt) in enumerate(entries)]
tree = StandardMerkleTree.of(values, ["uint256", "address", "uint256"], sort_leaves=False)
```

Output shape (keys are lowercase addresses):

```json
{
"merkleRoot": "0x6b3b…",
"totalAmount": "500000000000000000000000",
"claims": { "0x…": { "index": 0, "amount": "126…", "proof": ["0x…", …] } }
}
```

This skill ships that file verbatim and re-verifies every proof against the live
on-chain root before spending gas, so the bundled data is a convenience, not a
trust anchor. If the distributor is redeployed with a new tree, replace
`merkle.json` and rebuild — the on-chain-root match (`assert_root_matches`) will
otherwise fail closed.
Loading
Loading