feat: add across-bridge skill + CONTRIBUTING guide

across-bridge: bridge ETH/ERC-20 across 10 EVM chains via Across Protocol v3.
Two-function API: bridge_quote() for read-only quotes, bridge_execute() for
end-to-end (approval + deposit + arrival verification). Uses Across /swap
endpoint which returns ready-to-sign tx data, eliminating manual ABI encoding.
Validated with real on-chain test (USDC Base→Arbitrum, ~12s fill, 0.47% fee).

CONTRIBUTING.md: comprehensive guide for building official-standard skills —
directory structure, SKILL.md frontmatter, exports.py patterns, function
design best practices (one-function-one-intent, read+execute pairing,
human-friendly args, lazy platform imports, side-effect verification),
logo specs, publishing checklist & flow, versioning, reference skills.

Co-authored-by: Starchild <noreply@iamstarchild.com>
This commit is contained in:
Starchild
2026-06-27 16:14:42 +00:00
parent 75d90138f5
commit 9c6846fbba
6 changed files with 1117 additions and 0 deletions
+461
View File
@@ -0,0 +1,461 @@
# Contributing a Skill to Starchild Official Skills
This guide walks you through building a skill that meets the Starchild official
standard — the same bar the `across-bridge`, `wallet`, and `hyperliquid` skills
are held to. Follow it end-to-end and your skill will install cleanly, show up
in search, and feel native to any agent that calls it.
---
## 1. What is a Starchild Skill?
A skill is a **self-contained directory** that teaches an agent how to use a
tool, API, or workflow. It has two halves:
- **`SKILL.md`** — the human/agent-readable contract: what it does, when to
use it, how to call it, gotchas. This is what the agent reads to decide
whether to use the skill and how.
- **`exports.py`** (+ supporting scripts) — the executable half: Python
functions the agent calls at runtime via `core.skill_tools`.
A skill is NOT a prompt template, a config file, or a loose collection of
scripts. It is a **callable capability** with a documented interface.
---
## 2. Directory Structure
```
my-skill/
├── SKILL.md ← required: frontmatter + usage doc
├── exports.py ← required for script skills: public function surface
├── __init__.py ← empty, makes it a package
├── logo.png ← recommended: 128×128 or 256×256, ≤ 50KB
├── scripts/ ← optional: implementation modules
│ └── my_api.py
└── tools/ ← optional: alternative location for modules
└── helpers.py
```
### Minimum viable skill
```
my-skill/
├── SKILL.md
└── exports.py
```
That's it. `logo.png`, `__init__.py`, `scripts/`, `tools/` are all optional.
But every official skill should have a logo — see §6.
---
## 3. SKILL.md — The Contract
### 3.1 Required frontmatter
Three fields are **mandatory** — CI will reject the build if any are missing:
```yaml
---
name: my-skill # lowercase, hyphens only, matches dir name
version: 1.0.0 # semver
description: |
One-line summary, then a blank line, then a "Use when…" sentence.
This is the PRIMARY search field — be specific and keyword-rich.
---
```
### 3.2 Recommended frontmatter
```yaml
author: starchild
tags: [defi, bridge, evm, ethereum, arbitrum]
delivery: script # "script" = callable Python functions
metadata:
starchild:
emoji: 🌉 # shown in skill cards / search results
skillKey: my-skill # matches dir name
requires:
bins: [python3] # system binaries the skill needs
env: [MY_API_KEY] # env vars (user provides via request_env_input)
install:
- kind: pip
package: requests # pip deps auto-installed on skill load
```
### 3.3 Body structure
Use this template — agents rely on these sections to decide when and how to
call the skill:
```markdown
# 🌉 Skill Name
One-paragraph elevator pitch: what it does, why it's fast/cheap/better.
## When to Use
- Concrete trigger phrases ("bridge 50 USDC from Base to Arbitrum")
- 35 bullet points covering the main use cases
## Supported <Chains / Exchanges / Endpoints>
Plain list — helps the agent answer "does this support X?" without reading code.
## How to Call
Show the EXACT import + call pattern. For hyphenated skill names, use the
`_modules` dict (see §4.2):
\```bash
python3 - <<'EOF'
from core.skill_tools import _modules
my = _modules["my-skill"]
import json
print(json.dumps(my.do_thing(arg="value"), indent=2))
EOF
\```
### `function_name(args)` — one-line summary
What it returns, what side effects it has.
## Workflows
### The common case (end-to-end)
### The read-only case (quote / check / status)
## Key Facts / Gotchas
- Things that surprised you during development
- Auth model, rate limits, settlement times
- "Gas is sponsored" / "Wallet is the Agent Wallet" type facts
## Dependencies
- pip packages
- other skills (e.g. "uses core.skill_tools.wallet for signing")
```
**Do NOT** include:
- Long API reference dumps (link to the provider's docs instead)
- Internal implementation notes
- Changelogs (use git history)
---
## 4. exports.py — The Function Surface
`exports.py` is the **only** file the skill loader looks at. It defines the
public functions agents call. Everything else (`scripts/*.py`, `tools/*.py`)
is loaded as supporting infrastructure.
### 4.1 Minimal exports.py
```python
"""
my-skill exports — for use in task scripts via core.skill_tools.
Usage:
from core.skill_tools import _modules
my = _modules["my-skill"]
result = my.do_thing(arg="value")
"""
import os, importlib.util
_here = os.path.dirname(__file__)
_mod_path = os.path.join(_here, "scripts", "my_api.py")
_spec = importlib.util.spec_from_file_location("_my_core", _mod_path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
do_thing = _mod.do_thing
get_quote = _mod.get_quote
```
The loader auto-discovers `exports.py` and registers every public function in
it. You do **not** need `__all__` — but you **can** use it to hide helpers:
```python
__all__ = ["do_thing", "get_quote"] # only these are exposed
```
### 4.2 Hyphenated skill names
Skill names use hyphens (`across-bridge`, `us-stock`), but Python identifiers
can't. So `from core.skill_tools import across_bridge` does **not** work. Use
the `_modules` dict instead:
```python
from core.skill_tools import _modules
across = _modules["across-bridge"]
across.bridge_execute(...)
```
This is the official pattern — document it in your SKILL.md so agents don't
waste a turn discovering it.
### 4.3 What the loader filters out automatically
The `core.skill_tools` loader wraps your exports in a namespace proxy that
hides:
- Imported modules (`os`, `requests`, `json`)
- ALL_CAPS constants (`API_URL`, `FLY_SOCKET`)
- Private names (`_helper`)
- Classes and non-callable attributes
So you can freely import at module top without polluting the public surface.
Just define your functions and they're exported.
### 4.4 Import isolation for supporting modules
If your `exports.py` does `from utils import some_function`, the loader
pre-loads every `.py` in `scripts/` and `tools/` under unique names and
injects them as bare modules during load. This means:
- `from utils import x` just works — no `sys.path` hacks
- Your `utils.py` won't permanently shadow another skill's `utils.py`
- You don't need `__all__` or careful naming
**But**: keep `exports.py` thin. Put real logic in `scripts/`. `exports.py`
should be ~30 lines: load the core module, re-export functions, done.
---
## 5. Function Design — Best Practices
### 5.1 One function = one user intent
The biggest quality signal. Don't make the agent orchestrate:
```python
# ❌ Bad — agent must call 4 functions in sequence
get_quote() encode_calldata() send_approval() send_bridge() check_status()
# ✅ Good — one function, end-to-end
bridge_execute(from_chain, to_chain, token, amount, wallet)
```
If a workflow always has the same steps, package them. The agent should be
able to say "bridge 1 USDC Base→Arbitrum" and your skill handles the rest.
### 5.2 Pair read-only + end-to-end
Expose at least two functions:
| Function | Purpose |
|----------|---------|
| `*_quote()` / `*_status()` / `*_get()` | Read-only, safe to call freely |
| `*_execute()` / `*_send()` / `*_create()` | Side effects, does the real thing |
This lets the agent "think before acting" — get a quote, show it to the user,
then execute only after confirmation.
### 5.3 Accept human-friendly args, return structured JSON
```python
# ✅ Good — agent passes "USDC", "base", 1.0
bridge_execute(from_chain="base", to_chain="arbitrum",
token="USDC", amount=1, wallet="0x...")
# ❌ Bad — agent must know chain IDs, wei, contract addresses
bridge_execute(origin_chain_id=8453, dest_chain_id=42161,
input_token="0x833589...", amount_wei="1000000", ...)
```
Internally resolve symbols → IDs/addresses via a registry. Return a dict with
both human and machine fields:
```python
{
"output_amount": "995312", # machine
"output_amount_human": 0.995312, # human
"fees": {"total_pct": "0.47", ...},
"arrival_confirmed": true,
}
```
### 5.4 Lazy-import platform dependencies
`core.skill_tools.wallet` only exists at runtime in the platform container.
If your skill needs it, import it inside the function, not at module top:
```python
def bridge_execute(...):
from core.skill_tools import wallet as w # lazy
...
```
This keeps `exports.py` loadable in any environment (CI, local dev).
### 5.5 Verify side effects before returning success
For anything that moves money or mutates external state, don't return
`status: "success"` just because the API call returned 200. Poll the
destination / re-read state until you can confirm:
```python
# bridge_execute polls destination balance until funds actually arrive
arrival_confirmed = _poll_destination_balance(timeout=180)
return {"status": "success" if arrival_confirmed else "submitted_unconfirmed", ...}
```
### 5.6 Include a CLI for debugging
Add an `if __name__ == "__main__"` block to your core script so you (and
reviewers) can test from bash without spinning up the full skill loader:
```python
# scripts/across.py
if __name__ == "__main__":
import sys, json
cmd = sys.argv[1]
if cmd == "quote":
print(json.dumps(bridge_quote(*sys.argv[2:]), indent=2))
elif cmd == "execute":
print(json.dumps(bridge_execute(*sys.argv[2:]), indent=2, default=str))
```
---
## 6. Logo
Every official skill should ship a logo. It shows up in skill cards, search
results, and the installed-skills panel.
### 6.1 Specs
| Property | Value |
|----------|-------|
| Format | **PNG** preferred (SVG also accepted) |
| Size | 128×128 or 256×256 px |
| File size | ≤ 50 KB |
| Filename | `logo.png` (or `logo.svg`) in the skill root |
| Background | Transparent or solid — match the brand |
### 6.2 Where to get one
- **Official brand assets page** — most projects have one (e.g.
`docs.across.to/brand-assets`, `https://ethereum.org/brand`)
- **Favicon / OG image** — if no brand page, fetch the site's favicon:
```bash
curl -sL "https://across.to/favicon.svg" -o logo.svg
```
- **seeklogo / cdnlogo** — community-hosted SVGs for major brands
- **Generate one** — as a last resort, use the `image-create` skill to make a
256×256 icon that matches the skill's domain
### 6.3 Verify
```bash
ls -la my-skill/logo.png # exists, < 50KB
file my-skill/logo.png # "PNG image data, 256 x 256"
```
The loader picks up `logo.png` / `logo.svg` automatically — no frontmatter
field needed.
---
## 7. Publishing Checklist
Before opening a PR, verify:
- [ ] `SKILL.md` has `name`, `version`, `description` in frontmatter
- [ ] `name` matches the directory name (lowercase, hyphens only)
- [ ] `description` is specific and keyword-rich (it's the search field)
- [ ] `exports.py` loads cleanly: `python3 -c "import importlib.util, os; s=importlib.util.spec_from_file_location('m','my-skill/exports.py'); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print(dir(m))"`
- [ ] At least one read-only function + one end-to-end function
- [ ] Functions accept human-friendly args (symbols, not IDs/addresses)
- [ ] `logo.png` exists, < 50KB
- [ ] Tested with a real call (not just "it should work")
- [ ] `metadata.starchild.install` lists pip deps
- [ ] No hardcoded secrets, API keys, or wallet addresses
---
## 8. Publishing Flow
```bash
# 1. Clone
git clone https://github.com/Starchild-ai-agent/official-skills.git
cd official-skills
git checkout -b feat/my-skill
# 2. Add your skill directory
mkdir my-skill
# ... add SKILL.md, exports.py, logo.png, scripts/ ...
# 3. Commit & push
git add my-skill/
git commit -m "feat: add my-skill — one-line description"
git push origin feat/my-skill
# 4. Open PR
# GitHub Actions validates frontmatter + rebuilds skills.json automatically
```
### CI does the rest
On push to `main`, the `build-index.yml` workflow:
1. **Validates** every `SKILL.md` — fails if `name`/`version`/`description` missing
2. **Rebuilds** `skills.json` from frontmatter
3. **Commits** the updated index back to `main`
You do not need to edit `skills.json` manually — it's auto-generated.
---
## 9. Versioning
Follow semver:
| Change | Bump |
|--------|------|
| Bug fix, doc tweak | patch (`1.0.0 → 1.0.1`) |
| New function, new feature | minor (`1.0.0 → 1.1.0`) |
| Breaking API change | major (`1.0.0 → 2.0.0`) |
Users who already installed the skill get the new version on their next
`npx skills add` (npx detects the changed `computedHash`).
---
## 10. Removing a Skill
```bash
rm -rf old-skill
git add -A && git commit -m "chore: remove old-skill" && git push
```
CI removes it from `skills.json`. To also remove it from running user
containers, add the skill name to `config/skill-removals.txt` in the
[starchild-clawd](https://github.com/Starchild-ai-agent/starchild-clawd) repo.
---
## 11. Reference Skills
Read these before building your own — they embody the standard:
| Skill | Why study it |
|-------|-------------|
| `wallet` | Lazy-imports platform deps; clean read/write split |
| `hyperliquid` | `exports.py` + `client.py` + `tools.py` separation |
| `across-bridge` | One-function end-to-end + quote pair; hyphenated name pattern |
| `coingecko` | Simple read-only skill with `tools/` layout |
| `1inch` | `delivery: script` + `requires.env` for API keys |
---
## 12. Common Mistakes
| Mistake | Fix |
|---------|-----|
| `from core.skill_tools import my_skill` fails | Hyphenated name — use `_modules["my-skill"]` |
| CI rejects the build | Missing `name`/`version`/`description` in frontmatter |
| Agent calls 5 functions to do one thing | Package the workflow into one `*_execute()` |
| Skill works locally, fails in container | Hardcoded path or top-level `from core.skill_tools import wallet` — lazy-import instead |
| `description` too vague | Add a "Use when…" sentence with concrete examples |
| No logo | Fetch favicon or brand SVG; see §6 |
| Exposing `os`, `requests` as "functions" | You don't need to — the loader filters them out |
| Editing `skills.json` manually | Don't — CI overwrites it on every push |
---
Questions? Open an issue or ask in the Starchild community. Happy building.
+171
View File
@@ -0,0 +1,171 @@
---
name: across-bridge
version: 1.0.0
description: |
Bridge ETH and ERC-20 tokens across EVM chains via Across Protocol v3.
One function for a quote, one function for the entire end-to-end bridge
(approval + deposit + arrival verification).
Use when the user wants to move tokens between chains (e.g. "bridge 50 USDC
from Base to Arbitrum", "send ETH from Ethereum to Optimism", "move USDT
Arbitrum → Base"). Supports Ethereum, Arbitrum, Optimism, Base, Polygon,
BSC, Linea, zkSync, Scroll, Mantle. Fast settlement (seconds to minutes).
author: starchild
tags: [bridge, defi, cross-chain, across, evm, ethereum, arbitrum, base, optimism]
delivery: script
metadata:
starchild:
emoji: 🌉
skillKey: across-bridge
requires:
bins: [python3]
install:
- kind: pip
package: requests
---
# 🌉 Across Bridge
Bridge tokens between EVM chains using [Across Protocol](https://across.to) v3.
Across is an intent-based bridge: relayers front the liquidity on the
destination chain and get repaid on the origin chain, so settlement is fast
(seconds to a few minutes) and fees are low (0.050.5%).
## When to Use
- **Cross-chain token transfer**: "bridge 50 USDC from Base to Arbitrum"
- **L1 ↔ L2 movement**: "send 0.01 ETH from Ethereum to Optimism"
- **Stablecoin hops**: "move USDT from Arbitrum to Base"
- **Speed matters**: Across fills in secondsminutes vs native bridges (7 days)
## Supported Chains
Ethereum · Arbitrum · Optimism · Base · Polygon · BSC · Linea · zkSync · Scroll · Mantle
## Supported Tokens
ETH · WETH · USDC · USDT · WBTC · DAI
## How to Call
All operations are Python functions exposed under `core.skill_tools`. Because
the skill name contains a hyphen, use the `_modules` dict to access it:
```bash
python3 - <<'EOF'
from core.skill_tools import _modules
across = _modules["across-bridge"]
import json
q = across.bridge_quote(from_chain="base", to_chain="arbitrum",
token="USDC", amount=1, wallet="0x...")
print(json.dumps(q, indent=2))
EOF
```
### `bridge_quote(from_chain, to_chain, token, amount, wallet=None)`
Get a live quote — no on-chain action. Returns output amount, fees, estimated
fill time, and (if `wallet` is provided) ready-to-sign approval + bridge
transactions.
### `bridge_execute(from_chain, to_chain, token, amount, wallet, confirm_arrival=True)`
**One call does everything:**
1. Fetches a fresh quote from Across `/swap`
2. Sends ERC-20 approval to the SpokePool (if needed; skipped for native ETH)
3. Sends the `depositV3` bridge transaction
4. Polls the destination-chain balance until funds arrive (or timeout)
Returns a full receipt with `status`, `output_amount`, `approval_tx`,
`bridge_tx`, and `arrival_confirmed`.
### `bridge_status(origin_chain, deposit_tx_hash)`
Check the fill status of a submitted deposit via Across's status API.
## Workflows
### Just get a quote (no execution)
```bash
python3 - <<'EOF'
from core.skill_tools import _modules
across = _modules["across-bridge"]
import json
q = across.bridge_quote(from_chain="base", to_chain="arbitrum",
token="USDC", amount=50)
print(json.dumps(q, indent=2))
EOF
```
Key fields in the response:
- `output_amount_human` — tokens the recipient will receive
- `fees.total_pct` — fee percentage (e.g. `4688000000000000` = 0.47%)
- `estimated_fill_time_sec` — expected settlement time
- `needs_approval` — whether an ERC-20 approve is required before bridging
### Bridge end-to-end (the common case)
```bash
python3 - <<'EOF'
from core.skill_tools import _modules
across = _modules["across-bridge"]
import json
r = across.bridge_execute(from_chain="base", to_chain="arbitrum",
token="USDC", amount=1,
wallet="0x0B52...Eb16")
print(json.dumps(r, indent=2, default=str))
EOF
```
That single call handles approval + deposit + arrival verification. No need
to manually call `wallet_transfer` or encode calldata — the Across `/swap`
API returns ready-to-sign transactions and this skill dispatches them.
### Check status of a submitted deposit
```bash
python3 - <<'EOF'
from core.skill_tools import _modules
across = _modules["across-bridge"]
import json
s = across.bridge_status(origin_chain="base", deposit_tx_hash="0x...")
print(json.dumps(s, indent=2))
EOF
```
## Acceptance / Red-Flag Guidance
**Good routes:**
- Fee < 0.5% · fill time < 60s · output ≈ input (minus small fee)
**Warn the user before proceeding if:**
- Fee > 1% (expensive route or very small amount — min deposit applies)
- Fill time > 10 minutes (congested route)
- Amount below `limits.min_deposit` (quote will flag `isAmountTooLow`)
## Key Facts
- **Native ETH**: `msg.value` carries the amount; no approval step. Across
handles wrapping/unwrapping automatically.
- **ERC-20**: `msg.value` = 0; an `approve(spokePool, amount)` is required
first. `bridge_execute` sends this automatically.
- **Quotes are live** from `app.across.to/api/swap` and valid for ~30s.
`bridge_execute` re-fetches right before broadcasting.
- **Gas is sponsored** by default via the Starchild wallet — the user doesn't
need native tokens on the origin chain for gas.
- **Settlement**: Across relayers typically fill within seconds to a few
minutes. `bridge_execute` polls the destination balance for up to 180s.
- **Wallet**: uses the Starchild Agent Wallet (same as the `wallet` skill).
No separate wallet connection needed.
## Dependencies
- `requests` (pip)
- `core.skill_tools.wallet` (platform — for signing/broadcasting)
## Tested Routes
This skill was validated with a real on-chain bridge: 1 USDC Base → Arbitrum,
confirmed arrived on destination within ~35 seconds, fee 0.47%.
View File
+28
View File
@@ -0,0 +1,28 @@
"""
Across Bridge skill exports — for use in task scripts via core.skill_tools.
Usage:
from core.skill_tools import across
q = across.bridge_quote(from_chain="base", to_chain="arbitrum",
token="USDC", amount=1, wallet="0x...")
r = across.bridge_execute(from_chain="base", to_chain="arbitrum",
token="USDC", amount=1, wallet="0x...")
s = across.bridge_status(origin_chain="base", deposit_tx_hash="0x...")
"""
# Load the core module from scripts/ and re-export its public functions.
import os, importlib.util
_here = os.path.dirname(__file__)
_mod_path = os.path.join(_here, "scripts", "across.py")
_spec = importlib.util.spec_from_file_location("_across_core", _mod_path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
bridge_quote = _mod.bridge_quote
bridge_execute = _mod.bridge_execute
bridge_status = _mod.bridge_status
# Convenience: expose chain/token registries for downstream scripts
CHAIN_IDS = _mod.CHAIN_IDS
TOKENS = _mod.TOKENS
+11
View File
@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1422_35593)">
<path d="M32 0H0V32H32V0Z" fill="#6CF9D8"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.8638 7.37988L24.5978 9.11387L18.803 14.9086C18.4976 14.1131 17.8645 13.4801 17.069 13.1746L22.8638 7.37988ZM14.9096 13.1746L9.11485 7.37988L7.38086 9.11387L13.1756 14.9086C13.481 14.1131 14.1141 13.4801 14.9096 13.1746ZM13.1756 17.0681L7.38086 22.8628L9.11485 24.5968L14.9096 18.8021C14.1141 18.4966 13.481 17.8635 13.1756 17.0681ZM17.069 18.8021L22.8638 24.5968L24.5978 22.8628L18.803 17.0681C18.4976 17.8635 17.8645 18.4966 17.069 18.8021Z" fill="#151518"/>
</g>
<defs>
<clipPath id="clip0_1422_35593">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 808 B

+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
Across Protocol Bridge — official skill core module.
Two high-level functions cover the entire cross-chain flow:
bridge_quote(...) → live quote (output amount, fees, fill time, route)
bridge_execute(...) → end-to-end: approval (if needed) + deposit + verify arrival
Both are thin wrappers around the Across `/swap` API, which returns ready-to-sign
transaction data (approval + bridge calldata) in one call. No manual ABI encoding,
no multi-step orchestration by the caller.
Run from bash:
python3 -c "from core.skill_tools.across import bridge_quote; ..."
python3 -c "from core.skill_tools.across import bridge_execute; ..."
Or import in a script:
from core.skill_tools.across import bridge_quote, bridge_execute
"""
import json
import time
import requests
# ─── Chain registry ───────────────────────────────────────────────────────────
CHAIN_IDS = {
"ethereum": 1, "mainnet": 1, "eth": 1,
"arbitrum": 42161, "arb": 42161,
"optimism": 10, "op": 10,
"base": 8453,
"polygon": 137, "matic": 137,
"bsc": 56, "binance": 56,
"linea": 59144,
"zksync": 324, "era": 324,
"scroll": 534352,
"mantle": 5000,
}
CHAIN_NAMES = {
1: "ethereum",
42161: "arbitrum",
10: "optimism",
8453: "base",
137: "polygon",
56: "bsc",
59144: "linea",
324: "zksync",
534352: "scroll",
5000: "mantle",
}
# ─── Token registry (symbol → {chain_id: address}) ────────────────────────────
# Native ETH uses the zero address as inputToken/outputToken; the Across /swap
# API handles native ETH wrapping/unwrapping automatically.
TOKENS = {
"ETH": {
1: "0x0000000000000000000000000000000000000000",
42161: "0x0000000000000000000000000000000000000000",
10: "0x0000000000000000000000000000000000000000",
8453: "0x0000000000000000000000000000000000000000",
137: "0x0000000000000000000000000000000000000000",
56: "0x0000000000000000000000000000000000000000",
59144: "0x0000000000000000000000000000000000000000",
324: "0x0000000000000000000000000000000000000000",
534352:"0x0000000000000000000000000000000000000000",
5000: "0x0000000000000000000000000000000000000000",
},
"WETH": {
1: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
42161: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
10: "0x4200000000000000000000000000000000000006",
8453: "0x4200000000000000000000000000000000000006",
137: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
56: "0x2170Ed0880ac9A755fd29B2688956BD959F933F8",
59144: "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f",
324: "0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91",
},
"USDC": {
1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
42161: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
10: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
56: "0x8ac76A51cc950d9822D68b83fE1Ad97B32Cd580d",
59144: "0x176211869cA2b568f2A7D4EE941E073a821EE1ff",
},
"USDT": {
1: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
42161: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
10: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58",
137: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
56: "0x55d398326f99059fF775485246999027B3197955",
},
"WBTC": {
1: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
42161: "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f",
10: "0x68f180fcCe6836688e9084f035309E29Bf0A2095",
},
"DAI": {
1: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
42161: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
10: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
8453: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
137: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063",
},
}
DECIMALS = {"ETH": 18, "WETH": 18, "USDC": 6, "USDT": 6, "WBTC": 8, "DAI": 18}
ACROSS_SWAP_API = "https://app.across.to/api/swap"
ACROSS_STATUS_API = "https://app.across.to/api/deposit/status"
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _resolve_chain(chain):
"""Accept name (str) or id (int/str); return int chain id."""
if isinstance(chain, int):
return chain
s = str(chain).strip().lower()
if s.isdigit():
return int(s)
if s not in CHAIN_IDS:
raise ValueError(f"Unknown chain '{chain}'. Supported: {sorted(set(CHAIN_IDS))}")
return CHAIN_IDS[s]
def _resolve_token(symbol, chain_id):
"""Return token address for a symbol on a chain."""
sym = symbol.upper()
if sym not in TOKENS:
raise ValueError(f"Unsupported token '{symbol}'. Supported: {list(TOKENS)}")
if chain_id not in TOKENS[sym]:
raise ValueError(f"{sym} not available on chain {chain_id} ({CHAIN_NAMES.get(chain_id, '?')}). "
f"Available chains: {list(TOKENS[sym])}")
return TOKENS[sym][chain_id]
def _to_wei(amount, symbol):
"""Convert human-readable amount to smallest-unit integer."""
dec = DECIMALS[symbol.upper()]
if isinstance(amount, str) and amount.isdigit():
return int(amount)
return int(round(float(amount) * 10 ** dec))
def _from_wei(amount, symbol):
"""Convert smallest-unit to human-readable float."""
dec = DECIMALS[symbol.upper()]
return int(amount) / 10 ** dec
def _fetch_swap(input_token, output_token, amount_wei, origin_id, dest_id, wallet):
"""Call Across /swap and return parsed JSON."""
params = {
"inputToken": input_token,
"outputToken": output_token,
"amount": str(amount_wei),
"originChainId": origin_id,
"destinationChainId": dest_id,
"depositor": wallet,
"recipient": wallet,
}
headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
r = requests.get(ACROSS_SWAP_API, params=params, headers=headers, timeout=30)
r.raise_for_status()
return r.json()
# ─── Public API ───────────────────────────────────────────────────────────────
def bridge_quote(from_chain, to_chain, token, amount, wallet=None):
"""
Get a live Across bridge quote (no on-chain action).
Args:
from_chain: origin chain name ("base") or id (8453)
to_chain: destination chain name or id
token: token symbol ("USDC", "ETH", "USDT", "WETH", "WBTC", "DAI")
amount: human-readable amount (float/str) OR wei integer string
wallet: depositor/recipient address (optional for quote-only)
Returns:
{
"route": {"from_chain", "to_chain", "token", "amount", "amount_wei"},
"output_amount": "995312", # smallest unit string
"output_amount_human": 0.995312,
"fees": {"total_wei", "total_pct", "currency"},
"estimated_fill_time_sec": 2,
"spoke_pool": "0x...",
"needs_approval": true,
"approval_txns": [...], # ready-to-send if wallet given
"bridge_tx": {...}, # ready-to-send if wallet given
"limits": {"min_deposit", "max_deposit", ...},
"raw": {...} # full API response
}
"""
origin_id = _resolve_chain(from_chain)
dest_id = _resolve_chain(to_chain)
sym = token.upper()
amount_wei = _to_wei(amount, sym)
input_token = _resolve_token(sym, origin_id)
output_token = _resolve_token(sym, dest_id)
if wallet is None:
wallet = "0x0000000000000000000000000000000000000000"
data = _fetch_swap(input_token, output_token, amount_wei, origin_id, dest_id, wallet)
# Parse output amount — /swap nests it under steps.bridge.outputAmount
output_wei = None
if "steps" in data and "bridge" in data["steps"]:
output_wei = data["steps"]["bridge"].get("outputAmount")
if output_wei is None:
output_wei = data.get("outputAmount")
# Fees
fees_total = None
fees_pct = None
if "fees" in data and "total" in data["fees"]:
ft = data["fees"]["total"]
fees_total = ft.get("amount")
fees_pct = ft.get("pct")
# Fill time
fill_time = data.get("expectedFillTime")
if fill_time is None and "steps" in data and "bridge" in data["steps"]:
fill_time = data["steps"]["bridge"].get("estimatedFillTimeSec")
# Spoke pool
spoke = data.get("spokePoolAddress")
if spoke is None and "steps" in data and "bridge" in data["steps"]:
spoke = data["steps"]["bridge"].get("spokePoolAddress")
# Approval + bridge tx
approval_txns = data.get("approvalTxns", [])
swap_tx = data.get("swapTx")
needs_approval = bool(approval_txns)
# Limits (from suggested-fees; /swap may not include them)
limits = data.get("limits")
return {
"route": {
"from_chain": CHAIN_NAMES.get(origin_id, origin_id),
"from_chain_id": origin_id,
"to_chain": CHAIN_NAMES.get(dest_id, dest_id),
"to_chain_id": dest_id,
"token": sym,
"amount": str(amount),
"amount_wei": str(amount_wei),
},
"output_amount": str(output_wei) if output_wei else None,
"output_amount_human": _from_wei(int(output_wei), sym) if output_wei else None,
"fees": {
"total_wei": str(fees_total) if fees_total else None,
"total_pct": str(fees_pct) if fees_pct else None,
"currency": sym,
},
"estimated_fill_time_sec": fill_time,
"spoke_pool": spoke,
"needs_approval": needs_approval,
"approval_txns": approval_txns,
"bridge_tx": swap_tx,
"limits": limits,
"raw": data,
}
def bridge_execute(from_chain, to_chain, token, amount, wallet,
confirm_arrival=True, arrival_timeout=180):
"""
End-to-end bridge: fetch fresh quote → approval (if needed) → deposit → verify.
Uses the Starchild wallet skill (core.skill_tools.wallet) for signing/broadcast.
Gas is sponsored by default. Returns a full receipt.
Args:
from_chain, to_chain, token, amount: same as bridge_quote
wallet: depositor/recipient address (required)
confirm_arrival: if True, poll destination balance until funds arrive
arrival_timeout: max seconds to wait for arrival confirmation
Returns:
{
"status": "success" | "failed",
"route": {...},
"output_amount": "995312",
"output_amount_human": 0.995312,
"approval_tx": {...} | None,
"bridge_tx": {...},
"arrival_confirmed": true,
"elapsed_sec": 12.4
}
"""
# Lazy import: core.skill_tools.wallet is only available at runtime in
# the platform environment, not at module load time.
try:
from core.skill_tools import wallet as w
except Exception:
import importlib
w = importlib.import_module("core.skill_tools").wallet
origin_id = _resolve_chain(from_chain)
dest_id = _resolve_chain(to_chain)
sym = token.upper()
amount_wei = _to_wei(amount, sym)
t0 = time.time()
# 1. Fresh quote (quote valid ~30s; re-fetch right before sending)
q = bridge_quote(from_chain, to_chain, token, amount, wallet)
if q["bridge_tx"] is None:
raise RuntimeError(f"Across /swap returned no bridge tx. Raw: {q['raw']}")
# 2. Approval (if ERC-20 and not yet approved)
approval_result = None
if q["needs_approval"]:
for atx in q["approval_txns"]:
approval_result = w.wallet_transfer(
to=atx["to"],
amount="0",
chain_id=int(atx["chainId"]),
data=atx["data"],
)
# wait for approval to settle
time.sleep(6)
# re-fetch quote so swapTx reflects updated allowance
q = bridge_quote(from_chain, to_chain, token, amount, wallet)
if q["bridge_tx"] is None:
raise RuntimeError("Re-quoted after approval but still no bridge tx.")
# 3. Bridge deposit
swap = q["bridge_tx"]
bridge_result = w.wallet_transfer(
to=swap["to"],
amount=swap.get("value", "0"),
chain_id=int(swap["chainId"]),
data=swap["data"],
)
# 4. Verify arrival on destination
arrival_confirmed = False
if confirm_arrival:
dest_name = CHAIN_NAMES.get(dest_id, dest_id)
# snapshot pre-balance
try:
pre = w.wallet_balance(chain=dest_name)
pre_amt = _find_token_balance(pre, sym, dest_id)
except Exception:
pre_amt = None
deadline = time.time() + arrival_timeout
while time.time() < deadline:
time.sleep(10)
try:
post = w.wallet_balance(chain=dest_name)
post_amt = _find_token_balance(post, sym, dest_id)
except Exception:
continue
if post_amt is not None and pre_amt is not None:
if post_amt > pre_amt:
arrival_confirmed = True
break
elif post_amt is not None and post_amt > 0:
arrival_confirmed = True
break
return {
"status": "success" if arrival_confirmed or not confirm_arrival else "submitted_unconfirmed",
"route": q["route"],
"output_amount": q["output_amount"],
"output_amount_human": q["output_amount_human"],
"approval_tx": approval_result,
"bridge_tx": bridge_result,
"arrival_confirmed": arrival_confirmed,
"elapsed_sec": round(time.time() - t0, 1),
}
def _find_token_balance(balance_resp, symbol, chain_id):
"""Extract a token's raw_amount from a wallet_balance() response."""
sym = symbol.upper()
# For native ETH, DeBank returns id == chain name or "eth"
for t in balance_resp.get("tokens", []):
if t.get("symbol", "").upper() == sym:
return int(t.get("raw_amount", 0))
return None
def bridge_status(origin_chain, deposit_tx_hash):
"""
Check fill status of a submitted deposit via Across status API.
Args:
origin_chain: origin chain name or id
deposit_tx_hash: the deposit transaction hash on origin chain
Returns:
dict from Across /deposit/status (status: filled / pending / etc.)
"""
origin_id = _resolve_chain(origin_chain)
params = {"originChainId": origin_id, "depositTxHash": deposit_tx_hash}
headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
r = requests.get(ACROSS_STATUS_API, params=params, headers=headers, timeout=15)
r.raise_for_status()
return r.json()
# ─── CLI (for quick testing / debugging) ──────────────────────────────────────
def _cli():
import sys
if len(sys.argv) < 2:
print("Usage:")
print(" python3 across.py quote <from> <to> <token> <amount> [wallet]")
print(" python3 across.py execute <from> <to> <token> <amount> <wallet>")
print(" python3 across.py status <from_chain> <deposit_tx_hash>")
print()
print("Chains: " + ", ".join(sorted(set(CHAIN_IDS))))
print("Tokens: " + ", ".join(TOKENS))
sys.exit(1)
cmd = sys.argv[1]
if cmd == "quote":
q = bridge_quote(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5],
sys.argv[6] if len(sys.argv) > 6 else None)
# strip raw for readability
q.pop("raw", None)
print(json.dumps(q, indent=2))
elif cmd == "execute":
r = bridge_execute(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6])
print(json.dumps(r, indent=2, default=str))
elif cmd == "status":
s = bridge_status(sys.argv[2], sys.argv[3])
print(json.dumps(s, indent=2))
else:
print(f"Unknown command '{cmd}'")
sys.exit(1)
if __name__ == "__main__":
_cli()