
1inch
- 286 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Use 1inch for development tasks
About
1inch: A skill for development. This provides functionality for development workflows.
- 1inch
1inch by the numbers
- 286 all-time installs (skills.sh)
- +6 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,374 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill 1inchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 286 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Use 1inch for development tasks
Files
1inch (Script-First, Install-and-Use)
This skill is designed for your architecture: do not rely on platform-injected tools.
- ✅ Uses only skill-local scripts under
skills/1inch/scripts/ - ✅ Agent executes scripts via
bash(python3 ...) - ✅ No
oneinch_*tool calls required
---
Why this version fixes “tool not found”
Old design depended on runtime tool registration (oneinch_quote, oneinch_swap, ...). If tool injection fails, agent is blocked.
New design uses deterministic local scripts:
1. call 1inch HTTP API directly 2. call wallet service directly (OIDC via /.fly/api) 3. print JSON result
So after install, the agent always has a runnable path.
---
Files
scripts/_oneinch_lib.py— shared client + wallet/OIDC helpersscripts/tokens.py— token search/listscripts/quote.py— quote onlyscripts/check_allowance.py— allowance checkscripts/approve.py— approve tx broadcastscripts/swap.py— swap execution (optional auto-approve)scripts/run_swap_flow.py— one-command flow (quote + optional approve + swap + post-trade balance verification)
---
Environment
Required:
ONEINCH_API_KEYWALLET_SERVICE_URL(default fallback exists)- sc-proxy connectivity for 1inch API (mandatory)
Assumptions:
- Running on Fly Machine with
/.fly/apiunix socket for OIDC token minting.
sc-proxy requirement (critical)
This skill enforces 1inch calls through sc-proxy.
- It reads proxy from
HTTP_PROXY/HTTPS_PROXYfirst, else falls back toPROXY_HOST+PROXY_PORT. - If no proxy env is found, scripts fail fast with a clear error instead of silently direct-connecting.
---
Supported chains
ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
---
Agent execution rules (IMPORTANT)
When user asks for 1inch actions, follow this exact pattern:
1. *Never call `oneinch_ tools** 2. Run local scripts with bash + python3 skills/1inch/scripts/<script>.py ...` 3. Parse JSON output and respond with concise summary 4. For write actions, always do post-check using wallet balance tools (if available) or rerun script-based checks
Canonical mapping
- “查 token 地址 / 列 token” →
tokens.py - “先报价 / 看能换多少” →
quote.py - “检查授权” →
check_allowance.py - “授权” →
approve.py - “执行兑换” →
swap.py
---
Command examples
1) Search token
python3 skills/1inch/scripts/tokens.py --chain polygon --search POL --limit 102) Quote: 1 USDC -> POL
python3 skills/1inch/scripts/quote.py --chain polygon --from USDC --to POL --amount 13) Check allowance (USDC)
python3 skills/1inch/scripts/check_allowance.py --chain polygon --token USDC4) Approve USDC (unlimited)
python3 skills/1inch/scripts/approve.py --chain polygon --token USDC5) Swap: 1 USDC -> POL (auto-approve if needed)
python3 skills/1inch/scripts/swap.py --chain polygon --from USDC --to POL --amount 1 --slippage 1.0 --auto-approve6) One-command full flow (recommended)
python3 skills/1inch/scripts/run_swap_flow.py --chain polygon --from USDC --to POL --amount 1 --slippage 1.0 --auto-approveTune retries when needed:
python3 skills/1inch/scripts/run_swap_flow.py --chain polygon --from USDC --to POL --amount 1 --slippage 1.0 --auto-approve --swap-retries 3 --swap-retry-backoff 2---
Default workflow for “买 X USDC 的 POL”
Preferred deterministic sequence:
1. run_swap_flow.py --auto-approve 2. Read JSON result and report quote, tx submission result, and verification deltas
Fallback manual sequence (if user asks step-by-step): 1. quote.py (confirm expected output) 2. swap.py --auto-approve 3. Verify with fresh balances (before/after)
If swap returns wallet policy rejection:
- load
wallet-policyskill - propose wildcard baseline (
DENY exportPrivateKey,ALLOW *) - after user confirms, rerun swap command
---
Error handling
Unknown chain→ ask user to choose supported chain1inch API 4xx/5xx→ show raw error;run_swap_flow.pyautomatically retries transient/swap5xx (default: 2 retries with exponential backoff)Not enough <token> balancefrom 1inch → likely symbol mapped to a different token variant (e.g. USDC.e vs USDC); rerun with explicit token address fromtokens.pyWallet API 4xx/5xx→ show raw error, do not fabricate tx hashinsufficient allowance(without auto-approve) → rerun with--auto-approveor runapprove.pypolicyrejection → propose policy update then retry
---
Notes
- Amount input is human units (e.g.
--amount 1= 1 USDC), script handles decimal conversion. - Token symbol resolution comes from 1inch
/tokenson the selected chain. - If a symbol has multiple variants on a chain (e.g.,
USDC/USDC.e), prefer passing token contract address explicitly to avoid ambiguity. - For native token input, use
native/ETH.
---
Quick smoke test
python3 skills/1inch/scripts/quote.py --chain polygon --from USDC --to POL --amount 1If this works, the skill is operational in script mode.
---
Script Reference (CLI args)
This skill exposes scripts (not Python functions). Run them via bash + python3 skills/1inch/scripts/<name>.py <args>. All scripts emit JSON on stdout — parse with json.loads(...) or just print the output.
Common arguments
| Arg | Required | Notes |
|---|---|---|
--chain | yes | One of ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis |
--from, --to | yes (swap/quote) | Token symbol (USDC, POL, etc.) or contract address. Use native / ETH for native gas token. |
--amount | yes (swap/quote) | Human units (e.g. 1 = 1 USDC, decimal conversion handled internally) |
--slippage | optional | Percent (default 1.0) |
--token | yes (allowance/approve) | Token symbol or address |
--auto-approve | optional flag | swap/run_swap_flow only — auto-broadcasts approve if allowance insufficient |
--swap-retries | optional | run_swap_flow only — retries on /swap 5xx (default 2) |
--swap-retry-backoff | optional | run_swap_flow only — backoff seconds (default 1) |
Scripts
| Script | Purpose | Required args |
|---|---|---|
tokens.py | List or search tokens on a chain | --chain + --search SYM and/or --limit N |
quote.py | Get quote (no broadcast) | --chain --from --to --amount |
check_allowance.py | Check ERC-20 allowance to 1inch router | --chain --token |
approve.py | Broadcast approve tx (unlimited) | --chain --token |
swap.py | Execute swap | --chain --from --to --amount (+ optional --slippage, --auto-approve) |
run_swap_flow.py | One-command quote + (auto-approve) + swap + post-trade balance check | Same as swap.py (+ optional retry args) |
Recommended flow
For ANY user "swap X for Y" request, default to run_swap_flow.py --auto-approve. It handles the full chain end-to-end and verifies via wallet balance read after broadcast.
If user asks "just quote, don't execute" → quote.py only.
Output schema (run_swap_flow.py)
JSON with these top-level keys:
quote—{fromToken, toToken, fromAmount, toAmount, estimatedGas, ...}approve—{required, txHash, success}(only if--auto-approve)swap—{txHash, success, error?}verification—{from_balance_before, from_balance_after, to_balance_before, to_balance_after, delta_in, delta_out}
If wallet returns a policy rejection, swap.success = false and swap.error will mention policy. Load the wallet-policy skill, propose a wildcard baseline, then rerun.
"""
1inch Extension — Same-Chain Swap + Fusion+ Cross-Chain (EVM↔SOL) + Limit Orders.
Supports EVM: Ethereum, Arbitrum, Base, Optimism, Polygon, BSC, Avalanche, Gnosis.
Cross-chain also supports Solana (chain ID 501) as src or dst.
Provides 16 tools:
- 7 same-chain: quote, tokens, check_allowance, approve, swap,
fusion_quote, fusion_swap
- 5 cross-chain: cross_chain_quote, cross_chain_swap, cross_chain_status,
sol_cross_chain_quote, sol_to_evm_swap
- 4 limit orders: get_orders, get_order, create_limit_order, cancel_limit_order
Architecture: All write ops use wallet_sign_transaction / wallet_sign_typed_data (EVM)
or wallet_sol_sign_transaction (Solana). No Fly Machine dependency.
"""
import logging
from typing import List
logger = logging.getLogger(__name__)
def register(api) -> List[str]:
"""
Extension entry point — register all 1inch tools.
Args:
api: ExtensionApi instance with registry and config
Returns:
List of registered tool names
"""
registered = []
try:
from .tools import (
# Read-only tools (3)
OneInchQuoteTool,
OneInchTokensTool,
OneInchCheckAllowanceTool,
# Write tools (2)
OneInchApproveTool,
OneInchSwapTool,
)
from .fusion_tools import (
# Cross-chain read-only tools (3)
CrossChainQuoteTool,
CrossChainStatusTool,
SolCrossChainQuoteTool,
# Cross-chain write tools (2)
CrossChainSwapTool,
SolToEvmSwapTool,
)
from .orderbook_tools import (
# Limit order read-only tools (2)
GetOrdersTool,
GetOrderTool,
# Limit order write tools (2)
CreateLimitOrderTool,
CancelLimitOrderTool,
)
# Same-chain tools
api.register_tool(OneInchQuoteTool())
api.register_tool(OneInchTokensTool())
api.register_tool(OneInchCheckAllowanceTool())
api.register_tool(OneInchApproveTool())
api.register_tool(OneInchSwapTool())
# Cross-chain Fusion+ tools (EVM↔EVM + EVM↔SOL)
api.register_tool(CrossChainQuoteTool())
api.register_tool(CrossChainSwapTool())
api.register_tool(CrossChainStatusTool())
api.register_tool(SolCrossChainQuoteTool())
api.register_tool(SolToEvmSwapTool())
# Limit order / Orderbook tools
api.register_tool(GetOrdersTool())
api.register_tool(GetOrderTool())
api.register_tool(CreateLimitOrderTool())
api.register_tool(CancelLimitOrderTool())
registered = [
# Same-chain
"oneinch_quote",
"oneinch_tokens",
"oneinch_check_allowance",
"oneinch_approve",
"oneinch_swap",
"oneinch_fusion_quote",
"oneinch_fusion_swap",
# Cross-chain EVM↔EVM / EVM→SOL
"oneinch_cross_chain_quote",
"oneinch_cross_chain_swap",
"oneinch_cross_chain_status",
# Cross-chain SOL→EVM
"oneinch_sol_cross_chain_quote",
"oneinch_sol_to_evm_swap",
# Limit orders
"oneinch_get_orders",
"oneinch_get_order",
"oneinch_create_limit_order",
"oneinch_cancel_limit_order",
]
logger.info(f"Registered 1inch tools ({len(registered)} tools)")
except Exception as e:
logger.warning(f"Failed to load 1inch tools: {e}")
return registered
# Extension metadata
EXTENSION_INFO = {
"name": "oneinch",
"version": "3.0.0",
"description": "1inch DEX aggregator — same-chain swap, Fusion+ cross-chain (EVM↔SOL), limit orders",
"tools": [
"oneinch_quote",
"oneinch_tokens",
"oneinch_check_allowance",
"oneinch_approve",
"oneinch_swap",
"oneinch_fusion_quote",
"oneinch_fusion_swap",
"oneinch_cross_chain_quote",
"oneinch_cross_chain_swap",
"oneinch_cross_chain_status",
"oneinch_sol_cross_chain_quote",
"oneinch_sol_to_evm_swap",
"oneinch_get_orders",
"oneinch_get_order",
"oneinch_create_limit_order",
"oneinch_cancel_limit_order",
],
"env_vars": [
"ONEINCH_API_KEY",
],
}
"""
1inch Swap API Client — async HTTP client for 1inch DEX aggregator.
Supports multiple EVM networks: Ethereum, Arbitrum, Base, Optimism, Polygon, BSC, Avalanche, Gnosis.
Uses the 1inch Swap API v6.1 for:
- Quote: price estimate without tx data
- Swap: full transaction data for execution
- Approve: ERC-20 token approval for 1inch router
- Tokens: supported token list on the selected network
Environment Variables:
- ONEINCH_API_KEY: 1inch Developer Portal API key (required)
- WALLET_SERVICE_URL: Privy wallet service URL (for address lookup)
"""
import logging
import os
from typing import Any, Dict, Optional
import aiohttp
from core.http_client import get_aiohttp_proxy_kwargs
logger = logging.getLogger(__name__)
SUPPORTED_CHAINS = {
"ethereum": 1,
"arbitrum": 42161,
"base": 8453,
"optimism": 10,
"polygon": 137,
"bsc": 56,
"avalanche": 43114,
"gnosis": 100,
}
NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
ROUTER_ADDRESS = "0x111111125421cA6dc452d289314280a0f8842A65" # 1inch v6 router (EIP-55 checksummed)
def resolve_chain(chain: str) -> int:
"""Map a chain name to its chain ID. Raises ValueError for unknown chains."""
chain_lower = chain.lower().strip()
if chain_lower not in SUPPORTED_CHAINS:
supported = ", ".join(sorted(SUPPORTED_CHAINS.keys()))
raise ValueError(f"Unknown chain '{chain}'. Supported: {supported}")
return SUPPORTED_CHAINS[chain_lower]
class OneInchClient:
"""
Async 1inch client for EVM networks.
All methods call the 1inch Swap API v6.1 with Bearer token auth.
"""
def __init__(self, chain_id: int, api_key: Optional[str] = None):
self.chain_id = chain_id
self._api_base = f"https://api.1inch.com/swap/v6.1/{chain_id}"
self.api_key = api_key or os.environ.get("ONEINCH_API_KEY", "")
if not self.api_key:
logger.warning("ONEINCH_API_KEY not set — 1inch API calls will fail")
# ── Internal helpers ─────────────────────────────────────────────────
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""GET request to 1inch API with Bearer auth."""
url = f"{self._api_base}{path}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
}
proxy_kw = get_aiohttp_proxy_kwargs(url)
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=15),
**proxy_kw,
) as resp:
if resp.status >= 400:
body = await resp.text()
raise Exception(f"1inch API {resp.status}: {body}")
return await resp.json()
# ── Address helper ───────────────────────────────────────────────────
_cached_address: Optional[str] = None
async def _get_address(self) -> str:
"""Get the agent's EVM address from wallet service (cached)."""
if self._cached_address:
return self._cached_address
from core.wallet_runtime import wallet_request as _wallet_request
data = await _wallet_request("GET", "/agent/wallet")
wallets = data if isinstance(data, list) else data.get("wallets", [])
for w in wallets:
if w.get("chain_type") == "ethereum":
self._cached_address = w["wallet_address"]
return self._cached_address
raise RuntimeError("No ethereum wallet found")
# ── Quote & Swap ─────────────────────────────────────────────────────
async def get_quote(self, src: str, dst: str, amount: str) -> dict:
"""
Get a swap quote (price estimate, no tx data).
Args:
src: Source token address
dst: Destination token address
amount: Amount in wei (smallest unit)
Returns: dict with dstAmount, gas, protocols (route info)
"""
params = {"src": src, "dst": dst, "amount": amount}
return await self._get("/quote", params)
async def get_swap(
self,
src: str,
dst: str,
amount: str,
from_addr: str,
slippage: float = 1.0,
) -> dict:
"""
Get swap transaction data for execution.
Args:
src: Source token address
dst: Destination token address
amount: Amount in wei
from_addr: Wallet address executing the swap
slippage: Slippage tolerance in percent (default 1.0%)
Returns: dict with tx {to, data, value, gas} and dstAmount
"""
params = {
"src": src,
"dst": dst,
"amount": amount,
"from": from_addr,
"slippage": str(slippage),
}
return await self._get("/swap", params)
# ── Token Approval ───────────────────────────────────────────────────
async def get_approve_spender(self) -> dict:
"""Get the 1inch router address (spender for approvals)."""
return await self._get("/approve/spender")
async def get_approve_transaction(
self, token_address: str, amount: Optional[str] = None
) -> dict:
"""
Get approval tx data for a token.
Args:
token_address: ERC-20 token to approve
amount: Amount to approve in wei (omit for unlimited)
Returns: dict with {to, data, value} for the approval tx
"""
params = {"tokenAddress": token_address}
if amount is not None:
params["amount"] = amount
return await self._get("/approve/transaction", params)
async def get_allowance(self, token_address: str, wallet_address: str) -> dict:
"""
Check current allowance for the 1inch router.
Args:
token_address: ERC-20 token address
wallet_address: Wallet to check
Returns: dict with allowance amount
"""
params = {
"tokenAddress": token_address,
"walletAddress": wallet_address,
}
return await self._get("/approve/allowance", params)
# ── Token List ───────────────────────────────────────────────────────
async def get_tokens(self) -> dict:
"""
Get all supported tokens on the configured network.
Returns: dict mapping address → {symbol, name, decimals, address, ...}
"""
return await self._get("/tokens")
"""
1inch DEX Aggregator — Native tool exports for agent routing.
Tools registered:
READ (9): oneinch_quote, oneinch_tokens, oneinch_check_allowance
oneinch_fusion_quote
oneinch_cross_chain_quote, oneinch_cross_chain_status
oneinch_get_orders, oneinch_get_order
oneinch_sol_cross_chain_quote
WRITE (7): oneinch_approve, oneinch_swap, oneinch_fusion_swap
oneinch_cross_chain_swap
oneinch_sol_to_evm_swap
oneinch_create_limit_order, oneinch_cancel_limit_order
All API calls via sc-proxy (credentials auto-injected).
No Fly Machine dependency. Works in any Starchild container.
Cross-chain: EVM→EVM/SOL uses wallet_sign_typed_data + build/evm (verified ETH→ARB, ETH→SOL 2025).
SOL→EVM uses wallet_sol_sign_transaction + build/solana (2025).
SOL internal swap: NOT available via 1inch API (dApp only). Use Jupiter skill instead.
"""
import os
import time
from core.http_client import proxied_get, proxied_post
SC_CALLER_ID = "skill:1inch"
SUPPORTED_CHAINS = {
"ethereum": 1, "arbitrum": 42161, "base": 8453,
"optimism": 10, "polygon": 137, "bsc": 56,
"avalanche": 43114, "gnosis": 100,
}
# SOL chain for cross-chain Fusion+
SOLANA_CHAIN_ID = 501
SOL_NATIVE_TOKEN = "SoNative11111111111111111111111111111111111" # 1inch alias for native SOL
NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
ROUTER_V6 = "0x111111125421cA6dc452d289314280a0f8842A65"
ORDERBOOK_BASE = "https://api.1inch.dev/orderbook/v4.0"
FUSION_BASE = "https://api.1inch.com/fusion-plus" # P0: .dev is dead, must use .com
# Limit Order Protocol v4 — same address as Router v6
LOP_CONTRACTS = {v: ROUTER_V6 for v in SUPPORTED_CHAINS.values()}
def _chain_id(chain: str) -> int:
c = chain.lower().strip()
if c not in SUPPORTED_CHAINS:
raise ValueError(f"Unknown chain '{chain}'. Supported: {', '.join(sorted(SUPPORTED_CHAINS))}")
return SUPPORTED_CHAINS[c]
def _api(chain_id: int, path: str, params: dict = None) -> dict:
url = f"https://api.1inch.dev/swap/v6.0/{chain_id}{path}"
resp = proxied_get(url, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise Exception(f"1inch API {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _ob_get(chain_id: int, path: str, params: dict = None) -> dict:
url = f"{ORDERBOOK_BASE}/{chain_id}{path}"
resp = proxied_get(url, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise Exception(f"Orderbook API {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _ob_post(chain_id: int, path: str, body: dict) -> dict:
url = f"{ORDERBOOK_BASE}/{chain_id}{path}"
resp = proxied_post(url, json=body, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise Exception(f"Orderbook API {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _get_wallet_address() -> str:
"""Get agent EVM address from platform wallet."""
try:
import asyncio
import sys
sys.path.insert(0, '/app')
from core.wallet_runtime import wallet_request as _wallet_request
data = asyncio.run(_wallet_request("GET", "/agent/wallet"))
for w in (data if isinstance(data, list) else data.get("wallets", [])):
if w.get("chain_type") == "ethereum":
return w["wallet_address"]
except Exception:
pass
return ""
# ══════════════════════════════════════════════════════════════════════════════
# SAME-CHAIN SWAP TOOLS
# ══════════════════════════════════════════════════════════════════════════════
def oneinch_quote(chain: str, src: str, dst: str, amount: str) -> dict:
"""Get a swap price quote from 1inch DEX aggregator (read-only, no tx sent).
Returns estimated output amount and route info WITHOUT executing a swap.
Use before swapping to check rates. Token addresses must be checksummed ERC-20 addresses.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
src: Source token address (use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE for native ETH)
dst: Destination token address
amount: Amount in wei (1 USDC = 1000000, 1 ETH = 1000000000000000000)
"""
cid = _chain_id(chain)
data = _api(cid, "/quote", {"src": src, "dst": dst, "amount": amount})
return {
"chain": chain,
"src": src, "dst": dst,
"srcAmount": amount,
"dstAmount": data.get("dstAmount", "0"),
"gas": data.get("gas"),
"protocols": data.get("protocols", []),
}
def oneinch_tokens(chain: str, search: str = "") -> dict:
"""Search or list supported tokens on a network via 1inch.
Use to find token contract addresses before quoting or swapping.
Token addresses differ between networks.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
search: Filter by token name or symbol (case-insensitive). Omit to list popular tokens.
"""
cid = _chain_id(chain)
data = _api(cid, "/tokens")
token_map = data.get("tokens", data) if isinstance(data, dict) else data
tokens = list(token_map.values()) if isinstance(token_map, dict) else token_map
if search:
q = search.lower()
tokens = [t for t in tokens if q in t.get("symbol", "").lower() or q in t.get("name", "").lower()]
result = [
{"address": t.get("address"), "symbol": t.get("symbol"),
"name": t.get("name"), "decimals": t.get("decimals")}
for t in tokens[:20]
]
return {"chain": chain, "tokens": result, "count": len(result)}
def oneinch_check_allowance(chain: str, token_address: str, wallet_address: str = "") -> dict:
"""Check if a token has sufficient allowance for the 1inch router.
Native ETH does not need approval. ERC-20 tokens must be approved before swapping.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
token_address: ERC-20 token address to check
wallet_address: Wallet to check (uses agent wallet if omitted)
"""
if token_address.lower() == NATIVE_TOKEN.lower():
return {"allowance": "unlimited", "needs_approval": False, "note": "Native ETH needs no approval"}
cid = _chain_id(chain)
if not wallet_address:
wallet_address = _get_wallet_address()
if not wallet_address:
return {"error": "wallet_address required"}
data = _api(cid, "/approve/allowance", {
"tokenAddress": token_address,
"walletAddress": wallet_address,
})
allowance = data.get("allowance", "0")
return {
"chain": chain, "token": token_address, "wallet": wallet_address,
"allowance": allowance, "needs_approval": allowance == "0",
}
def oneinch_approve(chain: str, token_address: str, amount: str = "") -> dict:
"""Approve an ERC-20 token for the 1inch router (on-chain tx).
Required before swapping ERC-20 tokens (not needed for native ETH).
Sends approval transaction via agent wallet (wallet_sign_transaction).
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
token_address: ERC-20 token address to approve
amount: Amount to approve in wei (omit for unlimited approval)
"""
cid = _chain_id(chain)
params = {"tokenAddress": token_address}
if amount:
params["amount"] = amount
tx_data = _api(cid, "/approve/transaction", params)
try:
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
result = asyncio.run(_wallet_request("POST", "/agent/transfer", {
"to": tx_data["to"],
"data": tx_data.get("data", "0x"),
"value": tx_data.get("value", "0"),
"amount": "0",
"chain_id": cid,
}))
return {"status": "approval_sent", "chain": chain, "token": token_address, "tx_hash": result.get("tx_hash", ""), "tx": result}
except Exception as e:
return {"error": str(e), "tx_data": tx_data}
def oneinch_swap(chain: str, src: str, dst: str, amount: str, slippage: float = 1.0) -> dict:
"""Execute a token swap via 1inch DEX aggregator (on-chain tx).
1inch finds the best route across 200+ DEXes and executes the swap.
For ERC-20 src tokens, check allowance first with oneinch_check_allowance.
Native ETH swaps do not need approval.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
src: Source token address (0xEeee...EEeE for native ETH)
dst: Destination token address
amount: Amount in wei
slippage: Slippage tolerance in percent (default 1.0)
"""
cid = _chain_id(chain)
wallet_address = _get_wallet_address()
if not wallet_address:
return {"error": "No ethereum wallet configured"}
swap_data = _api(cid, "/swap", {
"src": src, "dst": dst, "amount": amount,
"from": wallet_address, "slippage": str(slippage),
})
tx = swap_data.get("tx", {})
if not tx:
return {"error": "1inch returned no transaction data", "raw": swap_data}
try:
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
result = asyncio.run(_wallet_request("POST", "/agent/transfer", {
"to": tx["to"],
"data": tx.get("data", "0x"),
"value": tx.get("value", "0"),
"amount": tx.get("value", "0"),
"chain_id": cid,
}))
return {
"status": "swap_sent", "chain": chain,
"src": src, "dst": dst,
"srcAmount": amount, "dstAmount": swap_data.get("dstAmount"),
"tx_hash": result.get("tx_hash", ""),
"tx": result,
}
except Exception as e:
err = str(e)
if "policy" in err.lower():
return {"error": f"Policy violation: {err}. Use wallet_propose_policy to allow this swap."}
return {"error": err}
# ══════════════════════════════════════════════════════════════════════════════
# CROSS-CHAIN SWAP TOOLS (Fusion+)
# ══════════════════════════════════════════════════════════════════════════════
def oneinch_cross_chain_quote(
src_chain: str, dst_chain: str,
src_token: str, dst_token: str, amount: str
) -> dict:
"""Get a cross-chain swap quote via 1inch Fusion+ (read-only).
Returns estimated output for swapping tokens across different chains
(e.g., ETH on Ethereum → USDC on Arbitrum). No transaction executed.
Fusion+ is intent-based — resolvers handle gas on both chains.
Args:
src_chain: Source network — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
dst_chain: Destination network — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
src_token: Source token address on the source chain
dst_token: Destination token address on the destination chain
amount: Amount in wei
"""
src_id = _chain_id(src_chain)
dst_id = _chain_id(dst_chain)
if src_id == dst_id:
return {"error": f"Same chain ({src_chain}). Use oneinch_quote for same-chain swaps."}
wallet = _get_wallet_address() or "0x0000000000000000000000000000000000000000"
url = f"{FUSION_BASE}/quoter/v1.1/quote/receive"
resp = proxied_get(url, params={
"srcChain": str(src_id), "dstChain": str(dst_id),
"srcTokenAddress": src_token, "dstTokenAddress": dst_token,
"amount": amount, "walletAddress": wallet,
"enableEstimate": "true",
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
return {"error": f"Fusion+ API {resp.status_code}: {resp.text[:300]}"}
return resp.json()
def oneinch_cross_chain_status(order_hash: str) -> dict:
"""Check the status of a Fusion+ cross-chain swap order.
Args:
order_hash: The order hash returned from oneinch_cross_chain_swap
"""
if not order_hash:
return {"error": "order_hash required"}
url = f"{FUSION_BASE}/orders/v1.1/order/status/{order_hash}" # P0: v1.1
resp = proxied_get(url, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
return {"error": f"Fusion+ API {resp.status_code}: {resp.text[:300]}"}
return resp.json()
# ══════════════════════════════════════════════════════════════════════════════
# LIMIT ORDER TOOLS (Orderbook)
# ══════════════════════════════════════════════════════════════════════════════
ORDER_TYPES = {
"Order": [
{"name": "salt", "type": "uint256"},
{"name": "maker", "type": "address"},
{"name": "receiver", "type": "address"},
{"name": "makerAsset", "type": "address"},
{"name": "takerAsset", "type": "address"},
{"name": "makingAmount", "type": "uint256"},
{"name": "takingAmount", "type": "uint256"},
{"name": "makerTraits", "type": "uint256"},
]
}
def _maker_traits(expiry_seconds: int = 0, allow_partial: bool = True) -> int:
traits = 0
if not allow_partial:
traits |= (1 << 255)
if expiry_seconds > 0:
expiry_ts = int(time.time()) + expiry_seconds
traits |= ((expiry_ts & 0xFFFFFFFFFF) << 80)
return traits
def oneinch_get_orders(chain: str, wallet_address: str = "", page: int = 1, limit: int = 10) -> dict:
"""Get open limit orders on 1inch Orderbook for a wallet.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
wallet_address: Wallet to query (defaults to agent wallet)
page: Page number (default: 1)
limit: Results per page (default: 10, max: 100)
"""
cid = _chain_id(chain)
if not wallet_address:
wallet_address = _get_wallet_address()
if not wallet_address:
return {"error": "wallet_address required"}
data = _ob_get(cid, f"/address/{wallet_address}", {
"page": page, "limit": min(limit, 100), "sortBy": "createDateTime",
})
return {"chain": chain, "wallet": wallet_address, "orders": data}
def oneinch_get_order(chain: str, order_hash: str) -> dict:
"""Get a specific limit order by hash on 1inch Orderbook.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
order_hash: The order hash
"""
cid = _chain_id(chain)
return _ob_get(cid, f"/{order_hash}")
def _fetch_limit_order_params(
cid: int, wallet_address: str,
maker_asset: str, taker_asset: str,
making_amount: str,
) -> dict:
"""Fetch FeeTaker extension, receiver, and makerTraits from 1inch Limit Order quoter.
1inch Orderbook v4 REQUIRES a FeeTaker extension (710-char hex) in every order.
Extension encodes resolver whitelist, FeeTaker contract address, and fee params.
Cannot be constructed client-side — must be fetched from the Limit Order quoter API.
API: GET /orderbook/v4.0/{chainId}/build-order
Returns: extension, receiver (FeeTaker contract), makerTraits
"""
url = f"{ORDERBOOK_BASE}/{cid}/build-order"
resp = proxied_get(url, params={
"walletAddress": wallet_address,
"makerAsset": maker_asset,
"takerAsset": taker_asset,
"makingAmount": making_amount,
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code == 200:
data = resp.json()
order_data = data.get("order", data)
return {
"extension": order_data.get("extension", ""),
"receiver": order_data.get("receiver", ""),
"makerTraits": order_data.get("makerTraits", "0x0"),
}
# Fallback: try Fusion+ quoter for extension
# GET /fusion-plus/quoter/v1.0/{chainId}/limit-order/quote
url2 = f"https://api.1inch.dev/fusion-plus/quoter/v1.0/{cid}/limit-order/quote"
resp2 = proxied_get(url2, params={
"walletAddress": wallet_address,
"makerAsset": maker_asset,
"takerAsset": taker_asset,
"makingAmount": making_amount,
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp2.status_code == 200:
data2 = resp2.json()
order_data2 = data2.get("order", data2)
return {
"extension": order_data2.get("extension", ""),
"receiver": order_data2.get("receiver", ""),
"makerTraits": order_data2.get("makerTraits", "0x0"),
}
raise RuntimeError(
f"Cannot fetch limit order params: "
f"build-order={resp.status_code}, fusion-quoter={resp2.status_code}. "
f"Details: {resp.text[:200]}"
)
# Known FeeTaker contract address on Ethereum mainnet (from on-chain order analysis)
FEES_TAKER_CONTRACT = "0xc0dfdb9e7a392c3dbbe7c6fbe8fbc1789c9fe05e"
# Fixed FeeTaker extension extracted from real on-chain orders (354 bytes / 710 hex chars)
# This is the standard 1inch FeeTaker extension — stable across orders, only salt changes
_FIXED_EXTENSION = (
"0x00000142000000ae000000ae000000ae000000ae000000570000000000000000"
"c0dfdb9e7a392c3dbbe7c6fbe8fbc1789c9fe05e000000012c6406b09498030a"
"e3416b66dc74db31d09524fa87b1f76ea9a11ae13b29f5c555d18bd45f0b94f5"
"4a968fc90ed87a54c23dc480b395770895ad27ad6b0d95c0dfdb9e7a392c3dbb"
"e7c6fbe8fbc1789c9fe05e000000012c6406b09498030ae3416b66dc74db31d0"
"9524fa87b1f76ea9a11ae13b29f5c555d18bd45f0b94f54a968fc90ed87a54c2"
"3dc480b395770895ad27ad6b0d95c0dfdb9e7a392c3dbbe7c6fbe8fbc1789c9f"
"e05e01000000000000000000000000000000000000000090cbe4bdd538d6e9b3"
"79bff5fe72c3d67a521de5d18e5e7dc9b58ec02204d3b88277d7a54510981b00"
"0000012c6406b09498030ae3416b66dc74db31d09524fa87b1f76ea9a11ae13b"
"29f5c555d18bd45f0b94f54a968fc90ed87a54c23dc480b395770895ad27ad6b"
"0d95"
)
# Strip spaces to get clean hex (710 chars after "0x")
_FIXED_EXTENSION = "0x" + _FIXED_EXTENSION.replace("0x", "").replace("\n", "").replace(" ", "")
# makerTraits from real orders — enables partial + multi fill with no-price-improvement bit
_FIXED_MAKER_TRAITS = "0x4a000000000000000000000000000000000069ddce8b00000000000000000000"
def _compute_salt_with_extension(extension_hex: str) -> int:
"""Compute salt = (random96 << 160) | keccak160(extension) per LOP v4 spec."""
try:
from eth_hash.auto import keccak
ext_bytes = bytes.fromhex(extension_hex.replace("0x", ""))
ext_hash = keccak(ext_bytes)
except ImportError:
import hashlib
ext_bytes = bytes.fromhex(extension_hex.replace("0x", ""))
ext_hash = hashlib.sha3_256(ext_bytes).digest()
low160 = int.from_bytes(ext_hash, "big") & ((1 << 160) - 1)
high96 = int.from_bytes(os.urandom(12), "big") << 160
return high96 | low160
def oneinch_create_limit_order(
chain: str,
maker_asset: str, taker_asset: str,
making_amount: str, taking_amount: str,
expiry_seconds: int = 86400,
allow_partial_fill: bool = True,
) -> dict:
"""Create and submit a limit order on 1inch Orderbook.
Signs an EIP-712 order off-chain and submits to 1inch Orderbook.
Resolvers fill it when market price matches your limit price.
⚠️ 1inch Orderbook v4 requires a FeeTaker extension in every order.
Extension is fetched automatically from the 1inch API — no manual config needed.
Before creating: check & approve allowance with oneinch_check_allowance / oneinch_approve.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
maker_asset: Token address you are selling
taker_asset: Token address you want to receive
making_amount: Amount of maker_asset to sell, in wei
taking_amount: Minimum amount of taker_asset to receive, in wei
expiry_seconds: Order validity in seconds (default: 86400 = 24h, 0 = no expiry)
allow_partial_fill: Allow partial fills (default: True)
"""
cid = _chain_id(chain)
contract = LOP_CONTRACTS.get(cid)
if not contract:
return {"error": f"Limit orders not supported on chain {chain}"}
wallet_address = _get_wallet_address()
if not wallet_address:
return {"error": "No ethereum wallet configured"}
try:
# Step 1: Try to fetch FeeTaker extension from 1inch API
# Fall back to known-good fixed extension if API unavailable
try:
params = _fetch_limit_order_params(cid, wallet_address, maker_asset, taker_asset, making_amount)
extension = params["extension"]
receiver = params["receiver"]
maker_traits_raw = params["makerTraits"]
except Exception:
# Use fixed extension from on-chain analysis (ethereum mainnet)
extension = _FIXED_EXTENSION
receiver = FEES_TAKER_CONTRACT
maker_traits_raw = _FIXED_MAKER_TRAITS
# Step 2: Compute salt = (random96 << 160) | keccak160(extension)
salt = _compute_salt_with_extension(extension)
# Step 3: Parse makerTraits — keep API-provided value, apply expiry/partial override if needed
maker_traits_int = int(maker_traits_raw, 16) if isinstance(maker_traits_raw, str) else int(maker_traits_raw)
# Apply expiry if specified (non-zero)
if expiry_seconds > 0:
expiry_ts = int(time.time()) + expiry_seconds
maker_traits_int = (maker_traits_int & ~(0xFFFFFFFFFF << 80)) | ((expiry_ts & 0xFFFFFFFFFF) << 80)
# Apply no-partial-fill bit if requested
if not allow_partial_fill:
maker_traits_int |= (1 << 255)
# Step 4: Build order structs
order_struct = {
"salt": str(salt),
"maker": wallet_address,
"receiver": receiver,
"makerAsset": maker_asset,
"takerAsset": taker_asset,
"makingAmount": str(making_amount),
"takingAmount": str(taking_amount),
"makerTraits": hex(maker_traits_int),
"extension": extension,
}
typed_data_message = {
"salt": salt,
"maker": wallet_address,
"receiver": receiver,
"makerAsset": maker_asset,
"takerAsset": taker_asset,
"makingAmount": int(making_amount),
"takingAmount": int(taking_amount),
"makerTraits": maker_traits_int,
}
# Step 5: EIP-712 sign
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
sig_result = asyncio.run(_wallet_request("POST", "/agent/sign-typed-data", {
"chain_id": cid,
"domain": {
"name": "1inch Aggregation Router",
"version": "6",
"chainId": cid,
"verifyingContract": contract,
},
"types": ORDER_TYPES,
"primaryType": "Order",
"message": typed_data_message,
}))
signature = sig_result.get("signature", "")
if not signature:
return {"error": f"Wallet sign failed: {sig_result}"}
# Normalize v (ensure v >= 27)
sig_hex = signature.replace("0x", "")
if len(sig_hex) == 130:
v = int(sig_hex[-2:], 16)
if v < 27:
signature = "0x" + sig_hex[:-2] + format(v + 27, "02x")
# Step 6: Submit to 1inch Orderbook
# POST body: { signature, data: { ...order fields including extension } }
result = _ob_post(cid, "", {"signature": signature, "data": order_struct})
return {
"status": "order_submitted",
"chain": chain,
"order_hash": result.get("orderHash", result.get("hash", "")),
"maker_asset": maker_asset,
"taker_asset": taker_asset,
"making_amount": making_amount,
"taking_amount": taking_amount,
"expiry_seconds": expiry_seconds,
"extension_source": "api" if "params" in dir() else "fixed_fallback",
"raw": result,
}
except Exception as e:
err = str(e)
if "policy" in err.lower():
return {"error": f"Policy violation: {err}. Use wallet_propose_policy to allow signing."}
return {"error": err}
def oneinch_cancel_limit_order(chain: str, order_hash: str) -> dict:
"""Cancel a limit order on-chain via 1inch Limit Order Protocol.
Sends an on-chain cancellation transaction. Requires gas.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
order_hash: The order hash to cancel
"""
cid = _chain_id(chain)
contract = LOP_CONTRACTS.get(cid)
if not contract:
return {"error": f"Limit orders not supported on chain {chain}"}
try:
# Fetch order to get makerTraits
order_data = _ob_get(cid, f"/{order_hash}")
order_struct = order_data.get("data", {})
if not order_struct:
return {"error": f"Order {order_hash} not found"}
maker_traits = int(order_struct.get("makerTraits", "0"))
order_hash_bytes = bytes.fromhex(order_hash.replace("0x", "").zfill(64))
# cancelOrder(uint256 makerTraits, bytes32 orderHash)
selector = bytes.fromhex("2b155166")
calldata = selector + maker_traits.to_bytes(32, "big") + order_hash_bytes
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
result = asyncio.run(_wallet_request("POST", "/agent/transfer", {
"to": contract,
"data": "0x" + calldata.hex(),
"value": "0",
"amount": "0",
"chain_id": cid,
}))
return {"status": "cancel_sent", "order_hash": order_hash, "tx_hash": result.get("tx_hash", ""), "tx": result}
except Exception as e:
return {"error": str(e)}
# ══════════════════════════════════════════════════════════════════════════════
# CROSS-CHAIN SWAP EXECUTION (Fusion+)
# ══════════════════════════════════════════════════════════════════════════════
def _fusion_get(path: str, params: dict = None) -> dict:
"""Fusion+ API GET via sc-proxy."""
url = f"https://api.1inch.com/fusion-plus{path}"
resp = proxied_get(url, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion+ GET {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _fusion_post(path: str, body: dict, params: dict = None) -> dict:
"""Fusion+ API POST via sc-proxy."""
url = f"https://api.1inch.com/fusion-plus{path}"
resp = proxied_post(url, json=body, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion+ POST {resp.status_code}: {resp.text[:300]}")
text = resp.text.strip()
return resp.json() if text else {}
def _generate_secrets(count: int) -> list:
return [os.urandom(32) for _ in range(count)]
def _hash_secret(secret: bytes) -> str:
try:
from eth_utils import keccak
return "0x" + keccak(secret).hex()
except ImportError:
import hashlib
return "0x" + hashlib.sha3_256(secret).hexdigest()
def _normalize_v(signature: str) -> str:
sig_hex = signature.replace("0x", "")
if len(sig_hex) == 130:
v = int(sig_hex[-2:], 16)
if v < 27:
signature = "0x" + sig_hex[:-2] + format(v + 27, "02x")
return signature
def oneinch_cross_chain_swap(
src_chain: str, dst_chain: str,
src_token: str, dst_token: str,
amount: str, preset: str = "medium"
) -> dict:
"""Execute a cross-chain token swap via 1inch Fusion+ (intent-based atomic swap).
Fusion+ is gasless on the destination chain — resolvers handle gas on both sides.
Uses requests sync path + wallet_sign_typed_data (verified ETH→ARB, ~76s settlement).
Flow: quote → generate secrets → build order → sign EIP-712 → submit → poll → reveal secrets.
⚠️ Polling up to 5 minutes. For long waits, use sessions_spawn for background execution.
Args:
src_chain: Source network — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
dst_chain: Destination network — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
src_token: Source token address on source chain
dst_token: Destination token address on destination chain
amount: Amount in wei (e.g. 2 USDC = "2000000")
preset: Speed — "fast", "medium" (default), or "slow"
"""
src_id = _chain_id(src_chain)
dst_id = _chain_id(dst_chain)
if src_id == dst_id:
return {"error": f"Same chain ({src_chain}). Use oneinch_swap for same-chain swaps."}
if preset not in ("fast", "medium", "slow"):
return {"error": f"Invalid preset '{preset}'. Use: fast, medium, slow"}
wallet_address = _get_wallet_address()
if not wallet_address:
return {"error": "No ethereum wallet configured"}
src_token = src_token.lower()
dst_token = dst_token.lower()
try:
# 1. Quote
quote = _fusion_get("/quoter/v1.1/quote/receive", {
"srcChain": str(src_id),
"dstChain": str(dst_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": wallet_address,
"enableEstimate": "true",
})
quote_id = quote.get("quoteId", "")
if not quote_id:
return {"error": "Quote missing quoteId — ensure enableEstimate=true"}
dst_amount_est = quote.get("dstTokenAmount", "")
presets_data = quote.get("presets", {})
preset_info = presets_data.get(preset, presets_data.get("medium", {}))
secrets_count = preset_info.get("secretsCount", 1)
# 2. Generate secrets
secrets = _generate_secrets(secrets_count)
secret_hashes = [_hash_secret(s) for s in secrets]
# 3. Build order
build_result = _fusion_post(
"/quoter/v1.1/quote/build/evm",
body={"secretsHashList": secret_hashes, "preset": preset},
params={"quoteId": quote_id},
)
order_hash = build_result.get("orderHash", "")
typed_data = build_result.get("typedData", {})
extension = build_result.get("extension", "")
build_tx = build_result.get("transaction")
build_signature = build_result.get("signature")
if not typed_data:
return {"error": "Build API returned no typedData", "build_keys": list(build_result.keys())}
# 4. Sign
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
if build_tx:
# Native ETH flow: broadcast deposit tx, use pre-computed signature
asyncio.run(_wallet_request("POST", "/agent/transfer", {
"to": build_tx.get("to", ""),
"value": str(build_tx.get("value", "0")),
"amount": str(build_tx.get("value", "0")),
"chain_id": src_id,
"data": build_tx.get("data", ""),
}))
if not build_signature:
return {"error": "Build API returned transaction but no pre-computed signature"}
signature = build_signature
else:
# ERC-20 flow: sign EIP-712 typed data
sig_result = asyncio.run(_wallet_request("POST", "/agent/sign-typed-data", {
"chain_id": src_id,
"domain": typed_data.get("domain", {}),
"types": typed_data.get("types", {}),
"primaryType": typed_data.get("primaryType", ""),
"message": typed_data.get("message", {}),
}))
signature = sig_result.get("signature", "")
if not signature:
return {"error": f"Wallet returned no signature: {sig_result}"}
signature = _normalize_v(signature)
# 5. Submit
submit_payload = {
"order": typed_data.get("message", {}),
"signature": signature,
"quoteId": quote_id,
"extension": extension,
"srcChainId": src_id,
}
if secrets_count > 1:
submit_payload["secretHashes"] = secret_hashes
submit_result = _fusion_post("/relayer/v1.1/submit", submit_payload)
order_hash = submit_result.get("orderHash", order_hash)
if not order_hash:
return {"error": "Order submission returned no order hash"}
# 6. Poll (max 5 min)
MAX_POLL = 300
INTERVAL = 15
revealed = set()
start = time.time()
while time.time() - start < MAX_POLL:
# Reveal secrets if fills ready
if len(revealed) < secrets_count:
try:
fills = _fusion_get(f"/orders/v1.1/order/ready-to-accept-secret-fills/{order_hash}")
for fill in fills.get("fills", []):
idx = fill.get("idx", 0)
if idx not in revealed and idx < len(secrets):
_fusion_post("/relayer/v1.1/submit/secret", {
"orderHash": order_hash,
"secret": "0x" + secrets[idx].hex(),
})
revealed.add(idx)
except Exception:
pass
# Check status
try:
status = _fusion_get(f"/orders/v1.1/order/status/{order_hash}")
order_status = status.get("status", "").lower()
if order_status in ("executed", "expired", "refunded", "cancelled"):
return {
"status": order_status,
"order_hash": order_hash,
"src_chain": src_chain, "dst_chain": dst_chain,
"src_token": src_token, "dst_token": dst_token,
"src_amount": amount,
"dst_amount": status.get("dstAmount", status.get("takingAmount", dst_amount_est)),
"secrets_revealed": len(revealed),
"elapsed_seconds": int(time.time() - start),
}
except Exception:
pass
time.sleep(INTERVAL)
# Timeout — order is submitted, just didn't confirm in time
return {
"status": "submitted_polling_timeout",
"order_hash": order_hash,
"src_chain": src_chain, "dst_chain": dst_chain,
"src_amount": amount, "dst_amount_estimate": dst_amount_est,
"message": f"Order submitted but did not confirm within {MAX_POLL}s. "
"Use oneinch_cross_chain_status(order_hash) to check later.",
}
except Exception as e:
err = str(e)
if "policy" in err.lower():
return {"error": f"Policy violation: {err}. Use wallet_propose_policy to allow this operation."}
return {"error": err}
# ══════════════════════════════════════════════════════════════════════════════
# SOLANA CROSS-CHAIN TOOLS (Fusion+ SOL↔EVM)
# ══════════════════════════════════════════════════════════════════════════════
def _get_sol_wallet_address() -> str:
"""Get agent Solana address from platform wallet."""
try:
import asyncio
import sys
sys.path.insert(0, '/app')
from core.wallet_runtime import wallet_request as _wallet_request
data = asyncio.run(_wallet_request("GET", "/agent/wallet"))
for w in (data if isinstance(data, list) else data.get("wallets", [])):
if w.get("chain_type") == "solana":
return w["wallet_address"]
except Exception:
pass
return ""
def _b58_to_solana_tx_b64(tx_b58: str) -> str:
"""Convert 1inch base58 Solana tx message → proper versioned tx base64 for Privy signing.
1inch build/solana returns the tx MESSAGE (not full tx) in base58.
Privy /agent/sol/sign-transaction expects a full versioned Solana tx in base64:
[0x01 (1 sig)][64 zero bytes (sig placeholder)][message bytes]
Returns base64-encoded full tx ready for Privy signing.
"""
import base64
_ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
num = 0
for char in tx_b58.strip():
num = num * 58 + _ALPHA.index(char)
padding = len(tx_b58) - len(tx_b58.lstrip('1'))
msg_bytes = num.to_bytes((num.bit_length() + 7) // 8, 'big') if num > 0 else b''
msg_bytes = b'\x00' * padding + msg_bytes
full_tx = bytes([0x01]) + b'\x00' * 64 + msg_bytes
return base64.b64encode(full_tx).decode()
def _extract_sol_sig_b58(signed_b64: str) -> str:
"""Extract ed25519 signature from Privy-signed Solana tx and encode as base58."""
import base64
_ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
signed_bytes = base64.b64decode(signed_b64)
sig_64 = signed_bytes[1:65]
n = int.from_bytes(sig_64, 'big')
result = ''
while n:
n, r = divmod(n, 58)
result = _ALPHA[r] + result
for b in sig_64:
if b == 0:
result = '1' + result
else:
break
return result
def oneinch_sol_cross_chain_quote(
src_token: str, dst_chain: str, dst_token: str, amount: str
) -> dict:
"""Get a Solana→EVM cross-chain quote via 1inch Fusion+.
Returns estimated output for swapping SOL-chain tokens to an EVM chain.
No transaction executed — read only.
Use SOL_NATIVE = "SoNative11111111111111111111111111111111111" for native SOL.
USDC on Solana = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
Args:
src_token: Source token address on Solana (use SoNative... for native SOL)
dst_chain: Destination EVM network — ethereum, arbitrum, base, optimism, polygon, bsc
dst_token: Destination token address on the EVM chain
amount: Amount in lamports (native SOL: 1 SOL = 1_000_000_000, USDC: 1 USDC = 1_000_000)
"""
dst_id = _chain_id(dst_chain)
sol_wallet = _get_sol_wallet_address() or "11111111111111111111111111111111"
resp = proxied_get(f"{FUSION_BASE}/quoter/v1.1/quote/receive", params={
"srcChain": str(SOLANA_CHAIN_ID),
"dstChain": str(dst_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": sol_wallet,
"enableEstimate": "true",
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
return {"error": f"Fusion+ API {resp.status_code}: {resp.text[:300]}"}
data = resp.json()
preset_info = data.get("presets", {}).get("medium", {})
return {
"src_chain": "solana",
"dst_chain": dst_chain,
"src_token": src_token,
"dst_token": dst_token,
"src_amount": amount,
"dst_amount_estimate": data.get("dstTokenAmount", ""),
"quote_id": data.get("quoteId", ""),
"preset_medium": {
"auction_duration": preset_info.get("auctionDuration"),
"secrets_count": preset_info.get("secretsCount", 1),
},
}
def oneinch_sol_to_evm_swap(
src_token: str, dst_chain: str, dst_token: str,
amount: str, preset: str = "medium"
) -> dict:
"""Execute a Solana→EVM cross-chain swap via 1inch Fusion+.
Swaps tokens from Solana to an EVM chain (e.g. SOL→ETH, USDC@Solana→USDC@Ethereum).
The Solana wallet signs a Solana transaction; EVM side receives the tokens.
Flow: Quote → Build (Solana tx) → Sign with SOL wallet → Submit → Poll → Reveal secrets
⚠️ Polling up to 5 minutes. Use sessions_spawn for background execution.
Use SOL_NATIVE = "SoNative11111111111111111111111111111111111" for native SOL.
USDC on Solana = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
Args:
src_token: Source token address on Solana
dst_chain: Destination EVM network — ethereum, arbitrum, base, optimism, polygon, bsc
dst_token: Destination token address on the EVM chain
amount: Amount in lamports (1 SOL = 1_000_000_000, 1 USDC = 1_000_000)
preset: Speed — "fast", "medium" (default), or "slow"
"""
dst_id = _chain_id(dst_chain)
if preset not in ("fast", "medium", "slow"):
return {"error": f"Invalid preset '{preset}'. Use: fast, medium, slow"}
sol_wallet = _get_sol_wallet_address()
if not sol_wallet:
return {"error": "No Solana wallet configured"}
evm_wallet = _get_wallet_address()
if not evm_wallet:
return {"error": "No EVM wallet configured (needed as receiver on destination chain)"}
try:
# 1. Quote
quote_resp = proxied_get(f"{FUSION_BASE}/quoter/v1.1/quote/receive", params={
"srcChain": str(SOLANA_CHAIN_ID),
"dstChain": str(dst_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": sol_wallet,
"enableEstimate": "true",
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if quote_resp.status_code >= 400:
return {"error": f"Quote failed {quote_resp.status_code}: {quote_resp.text[:300]}"}
quote = quote_resp.json()
quote_id = quote.get("quoteId", "")
if not quote_id:
return {"error": "Quote missing quoteId — ensure enableEstimate=true"}
dst_amount_est = quote.get("dstTokenAmount", "")
presets_data = quote.get("presets", {})
preset_info = presets_data.get(preset, presets_data.get("medium", {}))
secrets_count = preset_info.get("secretsCount", 1)
# 2. Generate secrets
secrets = _generate_secrets(secrets_count)
secret_hashes = [_hash_secret(s) for s in secrets]
# 3. Build order — Solana path, receiver = EVM wallet
build_resp = proxied_post(
f"{FUSION_BASE}/quoter/v1.1/quote/build/solana",
json={"secretsHashList": secret_hashes, "preset": preset, "receiver": evm_wallet},
params={"quoteId": quote_id},
headers={"SC-CALLER-ID": SC_CALLER_ID},
)
if build_resp.status_code >= 400:
return {"error": f"Build failed {build_resp.status_code}: {build_resp.text[:300]}"}
build = build_resp.json()
order_hash = build.get("orderHash", "")
order_struct = build.get("order", {})
solana_tx = build.get("transaction", "") # base58-encoded Solana tx
if not solana_tx:
return {"error": "Build API returned no Solana transaction", "build_keys": list(build.keys())}
# 4. Sign Solana transaction
# 1inch returns tx message in base58; Privy requires full versioned tx in base64
tx_b64_for_privy = _b58_to_solana_tx_b64(solana_tx)
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
sign_result = asyncio.run(_wallet_request("POST", "/agent/sol/sign-transaction", {
"transaction": tx_b64_for_privy,
}))
signed_b64 = sign_result.get("signed_transaction", "")
if not signed_b64:
return {"error": f"SOL wallet sign failed: {sign_result}"}
# 5. Broadcast signed Solana tx to Solana mainnet
# NOTE: wallet_sol_transfer requires Solana gas sponsorship to be enabled
# on the platform (Privy). If not enabled, this will raise a 400 error.
# Contact platform support to enable Solana gas sponsorship.
try:
broadcast_result = asyncio.run(_wallet_request("POST", "/agent/sol/transfer", {
"transaction": signed_b64,
"caip2": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
}))
# Extract Solana tx hash from broadcast result
sol_tx_hash = broadcast_result.get("hash", broadcast_result.get("signature", ""))
except Exception as e:
err_str = str(e)
if "gas sponsorship" in err_str.lower() or "not configured" in err_str.lower():
return {
"error": "Platform Solana gas sponsorship not enabled",
"detail": err_str,
"order_hash": order_hash,
"signed_tx_b64": signed_b64,
"hint": "Enable Solana gas sponsorship on Privy platform config, then broadcast signed_tx_b64 to Solana mainnet",
}
raise
if not order_hash:
return {"error": "Submit returned no order hash"}
# 6. Poll (max 5 min)
MAX_POLL = 300
INTERVAL = 15
revealed = set()
start = time.time()
while time.time() - start < MAX_POLL:
# Reveal secrets when fills are ready
if len(revealed) < secrets_count:
try:
fills = _fusion_get(f"/orders/v1.1/order/ready-to-accept-secret-fills/{order_hash}")
for fill in fills.get("fills", []):
idx = fill.get("idx", 0)
if idx not in revealed and idx < len(secrets):
_fusion_post("/relayer/v1.1/submit/secret", {
"orderHash": order_hash,
"secret": "0x" + secrets[idx].hex(),
})
revealed.add(idx)
except Exception:
pass
# Check status
try:
status = _fusion_get(f"/orders/v1.1/order/status/{order_hash}")
order_status = status.get("status", "").lower()
if order_status in ("executed", "expired", "refunded", "cancelled"):
return {
"status": order_status,
"order_hash": order_hash,
"src_chain": "solana",
"dst_chain": dst_chain,
"src_token": src_token,
"dst_token": dst_token,
"src_amount": amount,
"dst_amount": status.get("dstAmount", status.get("takingAmount", dst_amount_est)),
"secrets_revealed": len(revealed),
"elapsed_seconds": int(time.time() - start),
}
except Exception:
pass
time.sleep(INTERVAL)
# Timeout
return {
"status": "submitted_polling_timeout",
"order_hash": order_hash,
"src_chain": "solana", "dst_chain": dst_chain,
"src_amount": amount, "dst_amount_estimate": dst_amount_est,
"message": f"Order submitted but did not confirm within {MAX_POLL}s. "
"Use oneinch_cross_chain_status(order_hash) to check later.",
}
except Exception as e:
err = str(e)
if "policy" in err.lower():
return {"error": f"Policy violation: {err}. Use wallet_propose_policy to allow this operation."}
return {"error": err}
# ══════════════════════════════════════════════════════════════════════════════
# FUSION SAME-CHAIN GASLESS SWAP (1inch Fusion Mode)
# ══════════════════════════════════════════════════════════════════════════════
# Resolvers pay gas on behalf of the user — no native token required.
# Fee is deducted from swap output. Uses @1inch/fusion-sdk via Node.js subprocess.
# Supported chains: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
# ══════════════════════════════════════════════════════════════════════════════
import json
import subprocess
import os as _os
_FUSION_SAME_CHAIN_BASE = "https://api.1inch.com/fusion"
_FUSION_NODE_SCRIPT = _os.path.join(
_os.path.dirname(__file__), "scripts", "fusion_node", "build_order.js"
)
_FUSION_NODE_CWD = _os.path.join(
_os.path.dirname(__file__), "scripts", "fusion_node"
)
# Native token placeholder used by 1inch API
_NATIVE = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
def _fusion1_get(chain_id: int, path: str, params: dict = None) -> dict:
"""Fusion same-chain API GET via sc-proxy."""
url = f"{_FUSION_SAME_CHAIN_BASE}/{path}"
resp = proxied_get(url, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion API {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _fusion1_post(chain_id: int, path: str, body: dict) -> dict:
"""Fusion same-chain API POST via sc-proxy."""
url = f"{_FUSION_SAME_CHAIN_BASE}/{path}"
resp = proxied_post(url, json=body, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion API {resp.status_code}: {resp.text[:300]}")
text = resp.text.strip()
return resp.json() if text else {}
def _build_fusion_order_via_node(
quote: dict, from_token: str, to_token: str,
wallet: str, chain_id: int, preset: str
) -> dict:
"""Use Node.js + @1inch/fusion-sdk to build FusionOrder typed data + extension.
Returns { typedData, orderStruct, extension, orderHash } or raises.
"""
input_data = json.dumps({
"quoteJson": quote,
"fromTokenAddress": from_token,
"toTokenAddress": to_token,
"walletAddress": wallet,
"chainId": chain_id,
"preset": preset,
})
result = subprocess.run(
["node", "build_order.js"],
input=input_data,
capture_output=True,
text=True,
timeout=20,
cwd=_FUSION_NODE_CWD,
)
if result.returncode != 0 or not result.stdout.strip():
raise RuntimeError(
f"Node build_order.js failed (exit {result.returncode}): "
f"{result.stderr[:300] or 'no output'}"
)
data = json.loads(result.stdout)
if "error" in data:
raise RuntimeError(f"FusionOrder build error: {data['error']}\n{data.get('stack','')}")
return data
def oneinch_fusion_quote(
chain: str, from_token: str, to_token: str, amount: str
) -> dict:
"""Get a gasless same-chain swap quote via 1inch Fusion Mode (read-only).
Fusion Mode lets users swap WITHOUT holding native gas tokens — resolvers
pay gas on both sides and deduct a small fee from the swap output.
No transaction executed. Use oneinch_fusion_swap to execute.
Supported chains: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
⚠️ For cross-chain swaps (ETH → ARB, etc.), use oneinch_cross_chain_quote instead.
Args:
chain: Network name — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
from_token: Source token address (0xEeee...EEeE for native ETH)
to_token: Destination token address
amount: Amount in wei (1 USDC = 1000000, 1 ETH = 1000000000000000000)
Returns:
fromAmount, toAmount (estimated output), preset options, quoteId
"""
cid = _chain_id(chain)
wallet = _get_wallet_address() or "0x0000000000000000000000000000000000000000"
resp = proxied_get(
f"{_FUSION_SAME_CHAIN_BASE}/quoter/v2.0/{cid}/quote/receive",
params={
"fromTokenAddress": from_token,
"toTokenAddress": to_token,
"amount": amount,
"walletAddress": wallet,
"enableEstimate": "true",
},
headers={"SC-CALLER-ID": SC_CALLER_ID},
)
if resp.status_code >= 400:
return {"error": f"Fusion quoter {resp.status_code}: {resp.text[:300]}"}
data = resp.json()
preset_name = data.get("recommended_preset", "fast")
preset_info = data.get("presets", {}).get(preset_name, {})
return {
"chain": chain,
"from_token": from_token,
"to_token": to_token,
"from_amount": data.get("fromTokenAmount"),
"to_amount": data.get("toTokenAmount"),
"quote_id": data.get("quoteId"),
"recommended_preset": preset_name,
"auction_duration_sec": preset_info.get("auctionDuration"),
"starts_in_sec": preset_info.get("startAuctionIn"),
"price_impact_pct": data.get("priceImpactPercent", 0),
"gasless": True,
"note": "Fusion Mode: resolver pays gas, fee deducted from output. Use oneinch_fusion_swap to execute.",
}
def oneinch_fusion_swap(
chain: str, from_token: str, to_token: str,
amount: str, preset: str = "fast"
) -> dict:
"""Execute a gasless same-chain swap via 1inch Fusion Mode.
Fusion Mode: resolvers pay gas on behalf of the user. No native gas token needed.
Fee is deducted from swap output (~0.1-0.3% friction vs standard swap).
✅ Solves the gas chicken-and-egg problem:
Users holding only ERC-20 tokens (e.g. USDC) can swap WITHOUT native ETH/POL/etc.
Flow:
1. Quote → Fusion quoter API
2. Build → FusionOrder via @1inch/fusion-sdk (Node.js subprocess)
3. Sign → EIP-712 typed data via agent wallet
4. Submit → Relayer API
5. Poll → Wait for resolver to fill (up to 5 min)
Args:
chain: Network — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
from_token: Source token address (must be ERC-20; native ETH not yet supported as src)
to_token: Destination token address (native ETH/POL/etc. supported as dst)
amount: Amount in wei
preset: "fast" (default), "medium", or "slow"
"""
cid = _chain_id(chain)
if preset not in ("fast", "medium", "slow"):
return {"error": f"Invalid preset '{preset}'. Use: fast, medium, slow"}
# Check allowance required (same as standard swap)
if from_token.lower() != _NATIVE:
alw = oneinch_check_allowance(chain, from_token)
if alw.get("needs_approval"):
return {
"error": "Token not approved for 1inch router",
"action_required": "Run oneinch_approve first, then retry oneinch_fusion_swap",
"token": from_token,
"chain": chain,
"needs_approval": True,
"hint": (
"Fusion Mode is gasless for the SWAP itself, but the one-time token "
"APPROVE still requires a small amount of native gas. After approving once, "
"all future Fusion swaps are fully gasless."
),
}
wallet = _get_wallet_address()
if not wallet:
return {"error": "No ethereum wallet configured"}
try:
# 1. Quote
resp = proxied_get(
f"{_FUSION_SAME_CHAIN_BASE}/quoter/v2.0/{cid}/quote/receive",
params={
"fromTokenAddress": from_token,
"toTokenAddress": to_token,
"amount": amount,
"walletAddress": wallet,
"enableEstimate": "true",
},
headers={"SC-CALLER-ID": SC_CALLER_ID},
)
if resp.status_code >= 400:
return {"error": f"Fusion quoter {resp.status_code}: {resp.text[:300]}"}
quote = resp.json()
quote_id = quote.get("quoteId", "")
if not quote_id:
return {"error": "Fusion quoter returned no quoteId"}
dst_amount_est = quote.get("toTokenAmount", "0")
effective_preset = preset if preset in quote.get("presets", {}) else quote.get("recommended_preset", "fast")
# 2. Build FusionOrder using Node.js SDK
build = _build_fusion_order_via_node(
quote, from_token, to_token, wallet, cid, effective_preset
)
typed_data = build["typedData"]
order_struct = build["orderStruct"]
extension = build["extension"]
order_hash = build["orderHash"]
# 3. Sign EIP-712
import asyncio
from core.wallet_runtime import wallet_request as _wallet_request
sig_result = asyncio.run(_wallet_request("POST", "/agent/sign-typed-data", {
"chain_id": cid,
"domain": typed_data.get("domain", {}),
"types": typed_data.get("types", {}),
"primaryType": typed_data.get("primaryType", "Order"),
"message": typed_data.get("message", {}),
}))
signature = sig_result.get("signature", "")
if not signature:
return {"error": f"Wallet signing failed: {sig_result}"}
signature = _normalize_v(signature)
# 4. Submit to Fusion relayer
submit_url = f"{_FUSION_SAME_CHAIN_BASE}/relayer/v2.0/{cid}/order/submit"
submit_resp = proxied_post(submit_url, json={
"order": order_struct,
"signature": signature,
"quoteId": quote_id,
"extension": extension,
}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if submit_resp.status_code >= 400:
return {"error": f"Fusion relayer submit {submit_resp.status_code}: {submit_resp.text[:300]}"}
submit_data = submit_resp.json() if submit_resp.text.strip() else {}
confirmed_hash = submit_data.get("orderHash", order_hash)
# 5. Poll for completion (max 5 min)
MAX_POLL, INTERVAL = 300, 15
start = time.time()
while time.time() - start < MAX_POLL:
try:
status_resp = proxied_get(
f"{_FUSION_SAME_CHAIN_BASE}/orders/v2.0/{cid}/order/status/{confirmed_hash}",
headers={"SC-CALLER-ID": SC_CALLER_ID},
)
if status_resp.status_code == 200:
st = status_resp.json()
order_status = st.get("status", "").lower()
if order_status in ("executed", "expired", "refunded", "cancelled"):
return {
"status": order_status,
"order_hash": confirmed_hash,
"chain": chain,
"from_token": from_token,
"to_token": to_token,
"from_amount": amount,
"to_amount": st.get("dstAmount", dst_amount_est),
"gasless": True,
"elapsed_seconds": int(time.time() - start),
}
except Exception:
pass
time.sleep(INTERVAL)
return {
"status": "submitted_polling_timeout",
"order_hash": confirmed_hash,
"chain": chain,
"from_amount": amount,
"to_amount_estimate": dst_amount_est,
"gasless": True,
"message": (
f"Order submitted but did not confirm within {MAX_POLL}s. "
"Resolvers are filling — check status with order_hash."
),
}
except Exception as e:
err = str(e)
if "policy" in err.lower():
return {"error": f"Policy violation: {err}. Use wallet_propose_policy to allow this operation."}
return {"error": err}
# Explicit module exports (used by some loaders/introspection paths)
__all__ = [
"oneinch_quote",
"oneinch_tokens",
"oneinch_check_allowance",
"oneinch_approve",
"oneinch_swap",
"oneinch_fusion_quote",
"oneinch_fusion_swap",
"oneinch_cross_chain_quote",
"oneinch_cross_chain_status",
"oneinch_cross_chain_swap",
"oneinch_sol_cross_chain_quote",
"oneinch_sol_to_evm_swap",
"oneinch_get_orders",
"oneinch_get_order",
"oneinch_create_limit_order",
"oneinch_cancel_limit_order",
]
"""
1inch Fusion+ Cross-Chain Swap Client — async HTTP client for intent-based atomic cross-chain swaps.
Uses the Fusion+ API (api.1inch.com/fusion-plus) for:
- Quote: Get pricing for cross-chain token swaps
- Order Submit: Submit signed cross-chain swap orders
- Order Status: Check order execution status
- Fill Management: Reveal secrets as resolvers fill orders
Supported networks: Same as classic swap (Ethereum, Arbitrum, Base, Optimism, Polygon, BSC, Avalanche, Gnosis).
Environment Variables:
- ONEINCH_API_KEY: 1inch Developer Portal API key (required)
"""
import logging
import os
from typing import Any, Dict, List, Optional
import aiohttp
from eth_utils import keccak
from core.http_client import get_aiohttp_proxy_kwargs
logger = logging.getLogger(__name__)
FUSION_API_BASE = "https://api.1inch.com/fusion-plus"
# ── Secret Management ─────────────────────────────────────────────────────────
def generate_secrets(count: int) -> List[bytes]:
"""Generate random 32-byte secrets for Fusion+ order fills."""
return [os.urandom(32) for _ in range(count)]
def hash_secret(secret: bytes) -> str:
"""Compute keccak256 hash of a secret. Returns 0x-prefixed hex string."""
return "0x" + keccak(secret).hex()
# ── Fusion+ API Client ───────────────────────────────────────────────────────
class FusionPlusClient:
"""
Async client for 1inch Fusion+ cross-chain swap API.
Handles quoting, order submission, status polling, and secret reveal.
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("ONEINCH_API_KEY", "")
if not self.api_key:
logger.warning("ONEINCH_API_KEY not set — Fusion+ API calls will fail")
# ── Internal helpers ─────────────────────────────────────────────────
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""GET request to Fusion+ API with Bearer auth."""
url = f"{FUSION_API_BASE}{path}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
}
proxy_kw = get_aiohttp_proxy_kwargs(url)
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=30),
**proxy_kw,
) as resp:
if resp.status >= 400:
body = await resp.text()
raise Exception(f"Fusion+ API {resp.status}: {body}")
return await resp.json()
async def _post(
self,
path: str,
json_body: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""POST request to Fusion+ API with Bearer auth."""
url = f"{FUSION_API_BASE}{path}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
proxy_kw = get_aiohttp_proxy_kwargs(url)
async with aiohttp.ClientSession() as session:
async with session.post(
url,
headers=headers,
json=json_body,
params=params,
timeout=aiohttp.ClientTimeout(total=30),
**proxy_kw,
) as resp:
if resp.status >= 400:
body = await resp.text()
raise Exception(f"Fusion+ API {resp.status}: {body}")
# Some endpoints return empty body on success
text = await resp.text()
if not text:
return {}
return await resp.json(content_type=None)
# ── Address helper ───────────────────────────────────────────────────
_cached_address: Optional[str] = None
async def get_address(self) -> str:
"""Get the agent's EVM address from wallet service (cached)."""
if self._cached_address:
return self._cached_address
from core.wallet_runtime import wallet_request as _wallet_request
data = await _wallet_request("GET", "/agent/wallet")
wallets = data if isinstance(data, list) else data.get("wallets", [])
for w in wallets:
if w.get("chain_type") == "ethereum":
self._cached_address = w["wallet_address"]
return self._cached_address
raise RuntimeError("No ethereum wallet found")
# ── Build Order ────────────────────────────────────────────────────────
async def build_order(
self,
quote_id: str,
secret_hashes: list,
preset: str = "medium",
receiver: str = "",
) -> dict:
"""
Build order via API — returns extension, orderHash, typedData.
The build endpoint constructs the complete order server-side,
including extension encoding, salt, and makerTraits. The quote
is referenced by quoteId (returned from get_quote with enableEstimate=true).
Args:
quote_id: The quoteId from get_quote() response
secret_hashes: List of 0x-prefixed keccak256 secret hashes
preset: Speed preset — "fast", "medium", "slow" (default: "medium")
receiver: Custom receiver address (optional, defaults to maker)
Returns: { "extension": "0x...", "orderHash": "0x...", "typedData": { ... } }
"""
body: Dict[str, Any] = {
"secretsHashList": secret_hashes,
"preset": preset,
}
if receiver:
body["receiver"] = receiver
return await self._post(
"/quoter/v1.1/quote/build/evm",
json_body=body,
params={"quoteId": quote_id},
)
# ── Quote ─────────────────────────────────────────────────────────────
async def get_quote(
self,
src_chain: int,
dst_chain: int,
src_token: str,
dst_token: str,
amount: str,
wallet_address: str,
) -> dict:
"""
Get a cross-chain swap quote.
Args:
src_chain: Source chain ID
dst_chain: Destination chain ID
src_token: Source token address
dst_token: Destination token address
amount: Amount in wei (smallest unit)
wallet_address: Wallet address for the quote
Returns: Quote with estimated output, presets, fees
"""
params = {
"srcChain": str(src_chain),
"dstChain": str(dst_chain),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": wallet_address,
"enableEstimate": "true",
}
return await self._get("/quoter/v1.1/quote/receive", params)
# ── Order Management ─────────────────────────────────────────────────
async def place_order(self, order_data: dict) -> str:
"""
Submit a signed cross-chain swap order.
Args:
order_data: Complete order payload (EvmSignedOrderInput)
Returns: Order hash
"""
result = await self._post("/relayer/v1.1/submit", order_data)
return result.get("orderHash", result.get("order_hash", ""))
async def get_order_status(self, order_hash: str) -> dict:
"""
Get the current status of a cross-chain order.
Args:
order_hash: The order hash returned from place_order
Returns: Status dict with status, fills, timestamps
"""
return await self._get(f"/orders/v1.1/order/status/{order_hash}")
async def get_ready_to_accept_fills(self, order_hash: str) -> dict:
"""
Check which fills are ready to accept (secrets can be revealed).
Args:
order_hash: The order hash
Returns: Dict with fills ready for secret reveal
"""
return await self._get(f"/orders/v1.1/order/ready-to-accept-secret-fills/{order_hash}")
async def submit_secret(self, order_hash: str, secret: str) -> None:
"""
Reveal a secret for a fill.
Args:
order_hash: The order hash
secret: The secret to reveal (0x-prefixed hex)
"""
await self._post("/relayer/v1.1/submit/secret", {
"orderHash": order_hash,
"secret": secret,
})
"""
1inch Fusion+ Cross-Chain Swap Tools — BaseTool subclasses for cross-chain swaps.
Read-only tools (2): oneinch_cross_chain_quote, oneinch_cross_chain_status
Write tools (1): oneinch_cross_chain_swap (long-running — recommend background task)
Architecture note:
All HTTP calls use proxied_get/proxied_post (sc-proxy sync path).
The previous aiohttp async path caused pending+empty tx_hash bugs and has been removed.
Verified: ETH→ARB 2 USDC swap, ~76s settlement (2025).
"""
import json
import logging
import os
import re
import time
from core.http_client import proxied_get, proxied_post
from core.tool import BaseTool, ToolContext, ToolResult
from .client import SUPPORTED_CHAINS, resolve_chain
logger = logging.getLogger(__name__)
SC_CALLER_ID = "skill:1inch"
FUSION_BASE = "https://api.1inch.com/fusion-plus"
MAX_POLL_TIME = 300 # 5 minutes
POLL_INTERVAL = 15 # seconds
# Reverse lookup: chain_id → chain_name
CHAIN_ID_TO_NAME = {v: k for k, v in SUPPORTED_CHAINS.items()}
# ── Internal sync helpers ─────────────────────────────────────────────────────
def _fusion_get(path: str, params: dict = None) -> dict:
"""Fusion+ API GET via sc-proxy (sync)."""
url = f"{FUSION_BASE}{path}"
resp = proxied_get(url, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion+ GET {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _fusion_post(path: str, body: dict, params: dict = None) -> dict:
"""Fusion+ API POST via sc-proxy (sync)."""
url = f"{FUSION_BASE}{path}"
resp = proxied_post(url, json=body, params=params or {}, headers={"SC-CALLER-ID": SC_CALLER_ID})
if resp.status_code >= 400:
raise RuntimeError(f"Fusion+ POST {resp.status_code}: {resp.text[:300]}")
text = resp.text.strip()
return resp.json() if text else {}
def _generate_secrets(count: int) -> list:
return [os.urandom(32) for _ in range(count)]
def _hash_secret(secret: bytes) -> str:
try:
from eth_utils import keccak
return "0x" + keccak(secret).hex()
except ImportError:
import hashlib
return "0x" + hashlib.sha3_256(secret).hexdigest()
def _normalize_v(signature: str) -> str:
sig_hex = signature.replace("0x", "")
if len(sig_hex) == 130:
v = int(sig_hex[-2:], 16)
if v < 27:
signature = "0x" + sig_hex[:-2] + format(v + 27, "02x")
logger.info(f"Normalized signature v: {v} -> {v + 27}")
return signature
async def _get_wallet_address() -> str:
try:
from core.wallet_runtime import wallet_request as _wallet_request
data = await _wallet_request("GET", "/agent/wallet")
wallets = data if isinstance(data, list) else data.get("wallets", [])
for w in wallets:
if w.get("chain_type") == "ethereum":
return w.get("wallet_address", "")
except Exception:
pass
return ""
# ── Read-Only Tools ──────────────────────────────────────────────────────────
class CrossChainQuoteTool(BaseTool):
"""Get a cross-chain swap quote via 1inch Fusion+."""
@property
def name(self) -> str:
return "oneinch_cross_chain_quote"
@property
def description(self) -> str:
return """Get a cross-chain swap price quote via 1inch Fusion+.
Returns the estimated output amount for swapping tokens across different chains
(e.g., ETH on Ethereum to USDC on Arbitrum). No transaction is executed.
Fusion+ uses intent-based atomic swaps — resolvers handle gas on both chains,
so the user doesn't need gas on the destination chain.
Parameters:
- src_chain: Source network name (required) — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
- dst_chain: Destination network name (required) — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
- src_token: Source token address on the source chain
- dst_token: Destination token address on the destination chain
- amount: Amount in wei (smallest unit)
Returns: estimated output amount, presets (slow/medium/fast), fees"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"src_chain": {"type": "string", "description": "Source network: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis"},
"dst_chain": {"type": "string", "description": "Destination network: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis"},
"src_token": {"type": "string", "description": "Source token address on the source chain"},
"dst_token": {"type": "string", "description": "Destination token address on the destination chain"},
"amount": {"type": "string", "description": "Amount in wei (smallest unit)"},
},
"required": ["src_chain", "dst_chain", "src_token", "dst_token", "amount"],
}
async def execute(
self, ctx: ToolContext,
src_chain: str = "", dst_chain: str = "",
src_token: str = "", dst_token: str = "", amount: str = "",
**kwargs,
) -> ToolResult:
if not src_chain or not dst_chain:
return ToolResult(success=False, error="'src_chain' and 'dst_chain' are required")
if not src_token or not dst_token or not amount:
return ToolResult(success=False, error="'src_token', 'dst_token', and 'amount' are required")
try:
src_chain_id = resolve_chain(src_chain)
dst_chain_id = resolve_chain(dst_chain)
except ValueError as e:
return ToolResult(success=False, error=str(e))
if src_chain_id == dst_chain_id:
return ToolResult(
success=False,
error=f"Source and destination chains are the same ({src_chain}). Use oneinch_quote for same-chain swaps.",
)
wallet = await _get_wallet_address() or "0x0000000000000000000000000000000000000000"
try:
data = _fusion_get("/quoter/v1.1/quote/receive", {
"srcChain": str(src_chain_id),
"dstChain": str(dst_chain_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": wallet,
"enableEstimate": "true",
})
return ToolResult(success=True, output=data)
except Exception as e:
return ToolResult(success=False, error=str(e))
class CrossChainStatusTool(BaseTool):
"""Check the status of a cross-chain swap order."""
@property
def name(self) -> str:
return "oneinch_cross_chain_status"
@property
def description(self) -> str:
return """Check the status of a cross-chain swap order on 1inch Fusion+.
Parameters:
- order_hash: The order hash returned from oneinch_cross_chain_swap
Returns: order status (pending/executed/expired/refunded), fill details, timestamps"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"order_hash": {"type": "string", "description": "The order hash from oneinch_cross_chain_swap"},
},
"required": ["order_hash"],
}
async def execute(self, ctx: ToolContext, order_hash: str = "", **kwargs) -> ToolResult:
if not order_hash:
return ToolResult(success=False, error="'order_hash' is required")
try:
data = _fusion_get(f"/orders/v1.1/order/status/{order_hash}")
return ToolResult(success=True, output=data)
except Exception as e:
return ToolResult(success=False, error=str(e))
# ── Write Tools ──────────────────────────────────────────────────────────────
class CrossChainSwapTool(BaseTool):
"""Execute a cross-chain swap via 1inch Fusion+."""
@property
def name(self) -> str:
return "oneinch_cross_chain_swap"
@property
def description(self) -> str:
return """Execute a cross-chain token swap via 1inch Fusion+ (intent-based atomic swap).
This is a LONG-RUNNING operation (up to 5 minutes). Recommended to run as a background task
via sessions_spawn so the user isn't blocked.
Fusion+ swaps are gasless — resolvers handle gas on both chains. The user signs an EIP-712
order and resolvers execute the swap atomically.
Flow: Get quote → Generate secrets → Build order → Sign EIP-712 → Submit → Poll for fills → Reveal secrets → Complete
All HTTP calls use sc-proxy sync path (verified ETH→ARB 2 USDC, ~76s, 2025).
Parameters:
- src_chain: Source network name (required) — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
- dst_chain: Destination network name (required) — ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis
- src_token: Source token address on the source chain
- dst_token: Destination token address on the destination chain
- amount: Amount in wei (smallest unit)
- preset: Speed preset — "fast", "medium", or "slow" (default: "medium")
Returns: order hash, final status, amounts"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"src_chain": {"type": "string", "description": "Source network: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis"},
"dst_chain": {"type": "string", "description": "Destination network: ethereum, arbitrum, base, optimism, polygon, bsc, avalanche, gnosis"},
"src_token": {"type": "string", "description": "Source token address on the source chain"},
"dst_token": {"type": "string", "description": "Destination token address on the destination chain"},
"amount": {"type": "string", "description": "Amount in wei (smallest unit)"},
"preset": {"type": "string", "description": "Speed preset: fast, medium, or slow (default: medium)"},
},
"required": ["src_chain", "dst_chain", "src_token", "dst_token", "amount"],
}
async def execute(
self, ctx: ToolContext,
src_chain: str = "", dst_chain: str = "",
src_token: str = "", dst_token: str = "",
amount: str = "", preset: str = "medium",
**kwargs,
) -> ToolResult:
if not src_chain or not dst_chain:
return ToolResult(success=False, error="'src_chain' and 'dst_chain' are required")
if not src_token or not dst_token or not amount:
return ToolResult(success=False, error="'src_token', 'dst_token', and 'amount' are required")
eth_addr_re = re.compile(r"^0x[0-9a-fA-F]{40}$")
if not eth_addr_re.match(src_token):
return ToolResult(success=False, error=f"Invalid src_token '{src_token}'. Must be 0x + 40 hex chars.")
if not eth_addr_re.match(dst_token):
return ToolResult(success=False, error=f"Invalid dst_token '{dst_token}'. Must be 0x + 40 hex chars.")
try:
src_chain_id = resolve_chain(src_chain)
dst_chain_id = resolve_chain(dst_chain)
except ValueError as e:
return ToolResult(success=False, error=str(e))
if src_chain_id == dst_chain_id:
return ToolResult(success=False, error=f"Same chain ({src_chain}). Use oneinch_swap for same-chain swaps.")
if preset not in ("fast", "medium", "slow"):
return ToolResult(success=False, error=f"Invalid preset '{preset}'. Use: fast, medium, slow")
wallet_address = await _get_wallet_address()
if not wallet_address:
return ToolResult(success=False, error="No ethereum wallet configured")
src_token = src_token.lower()
dst_token = dst_token.lower()
try:
# 1. Quote
quote = _fusion_get("/quoter/v1.1/quote/receive", {
"srcChain": str(src_chain_id),
"dstChain": str(dst_chain_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": wallet_address,
"enableEstimate": "true",
})
quote_id = quote.get("quoteId", "")
if not quote_id:
return ToolResult(success=False, error="Quote missing quoteId — ensure enableEstimate=true")
dst_amount_est = quote.get("dstTokenAmount", "")
preset_info = quote.get("presets", {}).get(preset, quote.get("presets", {}).get("medium", {}))
secrets_count = preset_info.get("secretsCount", 1)
# 2. Generate secrets
secrets = _generate_secrets(secrets_count)
secret_hashes = [_hash_secret(s) for s in secrets]
# 3. Build order
build_result = _fusion_post(
"/quoter/v1.1/quote/build/evm",
body={"secretsHashList": secret_hashes, "preset": preset},
params={"quoteId": quote_id},
)
order_hash = build_result.get("orderHash", "")
typed_data = build_result.get("typedData", {})
extension = build_result.get("extension", "")
build_tx = build_result.get("transaction")
build_signature = build_result.get("signature")
if not typed_data:
return ToolResult(success=False, error="Build API returned no typedData", output={"build_keys": list(build_result.keys())})
# 4. Sign
if build_tx:
# Native ETH: execute deposit tx, use pre-computed signature
from core.wallet_runtime import wallet_request as _wallet_request
asyncio.run(_wallet_request("POST", "/agent/send-transaction", {
"to": build_tx.get("to", ""),
"value": str(build_tx.get("value", "0")),
"chain_id": src_chain_id,
"data": build_tx.get("data", ""),
}))
if not build_signature:
return ToolResult(success=False, error="Build API returned transaction but no pre-computed signature")
signature = build_signature
else:
# ERC-20: sign EIP-712 typed data via _wallet_request
from core.wallet_runtime import wallet_request as _wallet_request
sig_result = await _wallet_request("POST", "/agent/sign-typed-data", {
"domain": typed_data.get("domain", {}),
"types": typed_data.get("types", {}),
"primaryType": typed_data.get("primaryType", ""),
"message": typed_data.get("message", {}),
})
signature = sig_result.get("signature", "") if isinstance(sig_result, dict) else ""
if not signature:
return ToolResult(success=False, error=f"Wallet returned no signature: {sig_result}")
signature = _normalize_v(signature)
# 5. Submit
submit_payload = {
"order": typed_data.get("message", {}),
"signature": signature,
"quoteId": quote_id,
"extension": extension,
"srcChainId": src_chain_id,
}
if secrets_count > 1:
submit_payload["secretHashes"] = secret_hashes
submit_result = _fusion_post("/relayer/v1.1/submit", submit_payload)
order_hash = submit_result.get("orderHash", order_hash)
if not order_hash:
return ToolResult(success=False, error="Order submission returned no order hash")
# 6. Poll (max 5 min)
revealed = set()
start = time.time()
while time.time() - start < MAX_POLL_TIME:
# Reveal secrets if fills ready
if len(revealed) < secrets_count:
try:
fills = _fusion_get(f"/orders/v1.1/order/ready-to-accept-secret-fills/{order_hash}")
for fill in fills.get("fills", []):
idx = fill.get("idx", 0)
if idx not in revealed and idx < len(secrets):
_fusion_post("/relayer/v1.1/submit/secret", {
"orderHash": order_hash,
"secret": "0x" + secrets[idx].hex(),
})
revealed.add(idx)
logger.info(f"Revealed secret {idx} for order {order_hash}")
except Exception as e:
logger.debug(f"Fill check (may be normal): {e}")
# Check status
try:
status = _fusion_get(f"/orders/v1.1/order/status/{order_hash}")
order_status = status.get("status", "").lower()
if order_status in ("executed", "expired", "refunded", "cancelled"):
return ToolResult(
success=(order_status == "executed"),
output={
"status": order_status,
"order_hash": order_hash,
"src_chain": src_chain, "dst_chain": dst_chain,
"src_token": src_token, "dst_token": dst_token,
"src_amount": amount,
"dst_amount": status.get("dstAmount", status.get("takingAmount", dst_amount_est)),
"secrets_revealed": len(revealed),
"elapsed_seconds": int(time.time() - start),
},
error=f"Order {order_status}" if order_status != "executed" else None,
)
except Exception as e:
err_str = str(e)
if "429" in err_str or "rate limit" in err_str.lower():
logger.warning("Rate limited on status check, backing off")
time.sleep(30)
else:
logger.warning(f"Status check error (order already submitted): {e}")
time.sleep(POLL_INTERVAL)
# Timeout — order is submitted, just didn't confirm in time
return ToolResult(
success=False,
output={
"status": "submitted_polling_timeout",
"order_hash": order_hash,
"src_chain": src_chain, "dst_chain": dst_chain,
"src_amount": amount, "dst_amount_estimate": dst_amount_est,
"message": f"Order submitted but did not confirm within {MAX_POLL_TIME}s. "
"Use oneinch_cross_chain_status(order_hash) to check later.",
},
error=f"Order timed out after {MAX_POLL_TIME}s. Use oneinch_cross_chain_status to check later.",
)
except Exception as e:
err = str(e)
logger.error(f"Cross-chain swap failed: {err}", exc_info=True)
if "policy" in err.lower():
return ToolResult(
success=False,
error=f"Policy violation: {err}. Use wallet_propose_policy to allow this operation.",
)
return ToolResult(success=False, error=err)
# ══════════════════════════════════════════════════════════════════════════════
# SOLANA CROSS-CHAIN TOOLS (SOL→EVM via Fusion+)
# Added: 2025. Verified API paths: build/solana, relayer submit srcChainId=501.
# SOL internal swap: NOT available via 1inch API — use Jupiter skill instead.
# ══════════════════════════════════════════════════════════════════════════════
SOLANA_CHAIN_ID = 501
SOL_NATIVE_TOKEN = "SoNative11111111111111111111111111111111111"
def _get_sol_wallet_address() -> str:
"""Get agent Solana wallet address."""
try:
from core.wallet_runtime import wallet_request as _wallet_request
info = asyncio.run(_wallet_request("GET", "/agent/wallet"))
for w in (info if isinstance(info, list) else info.get("wallets", [])):
if w.get("chain_type") == "solana":
return w["wallet_address"]
except Exception:
pass
return ""
class SolCrossChainQuoteTool(BaseTool):
"""Get a Solana→EVM cross-chain quote via 1inch Fusion+."""
@property
def name(self) -> str:
return "oneinch_sol_cross_chain_quote"
@property
def description(self) -> str:
return """Get a Solana→EVM cross-chain quote via 1inch Fusion+ (read-only).
Returns estimated output for swapping Solana tokens to an EVM chain.
Use SOL_NATIVE = "SoNative11111111111111111111111111111111111" for native SOL.
USDC on Solana = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
Parameters:
- src_token: Source token address on Solana
- dst_chain: Destination EVM network — ethereum, arbitrum, base, optimism, polygon, bsc
- dst_token: Destination token address on the EVM chain
- amount: Amount in lamports (1 SOL = 1_000_000_000, 1 USDC = 1_000_000)
Returns: estimated output, quote_id, preset info"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"src_token": {"type": "string", "description": "Source token on Solana (use SoNative... for native SOL)"},
"dst_chain": {"type": "string", "description": "Destination EVM network: ethereum, arbitrum, base, optimism, polygon, bsc"},
"dst_token": {"type": "string", "description": "Destination token address on the EVM chain"},
"amount": {"type": "string", "description": "Amount in lamports (1 SOL = 1_000_000_000)"},
},
"required": ["src_token", "dst_chain", "dst_token", "amount"],
}
async def execute(
self, ctx: ToolContext,
src_token: str = "", dst_chain: str = "",
dst_token: str = "", amount: str = "",
**kwargs,
) -> ToolResult:
if not src_token or not dst_chain or not dst_token or not amount:
return ToolResult(success=False, error="src_token, dst_chain, dst_token, amount are required")
try:
dst_chain_id = resolve_chain(dst_chain)
except ValueError as e:
return ToolResult(success=False, error=str(e))
sol_wallet = _get_sol_wallet_address() or "11111111111111111111111111111111"
try:
data = _fusion_get("/quoter/v1.1/quote/receive", {
"srcChain": str(SOLANA_CHAIN_ID),
"dstChain": str(dst_chain_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": sol_wallet,
"enableEstimate": "true",
})
preset_info = data.get("presets", {}).get("medium", {})
return ToolResult(success=True, output={
"src_chain": "solana",
"dst_chain": dst_chain,
"src_token": src_token,
"dst_token": dst_token,
"src_amount": amount,
"dst_amount_estimate": data.get("dstTokenAmount", ""),
"quote_id": data.get("quoteId", ""),
"preset_medium": {
"auction_duration": preset_info.get("auctionDuration"),
"secrets_count": preset_info.get("secretsCount", 1),
},
})
except Exception as e:
return ToolResult(success=False, error=str(e))
class SolToEvmSwapTool(BaseTool):
"""Execute a Solana→EVM cross-chain swap via 1inch Fusion+."""
@property
def name(self) -> str:
return "oneinch_sol_to_evm_swap"
@property
def description(self) -> str:
return """Execute a Solana→EVM cross-chain swap via 1inch Fusion+.
Swaps tokens from Solana to an EVM chain (e.g., SOL→USDC@Ethereum).
Solana wallet signs a Solana transaction; tokens arrive on EVM destination.
Flow: Quote → Build (Solana tx) → Sign with SOL wallet → Submit → Poll → Reveal secrets
⚠️ Long-running (up to 5 min). Use sessions_spawn for background execution.
Use SOL_NATIVE = "SoNative11111111111111111111111111111111111" for native SOL.
USDC on Solana = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
Parameters:
- src_token: Source token address on Solana
- dst_chain: Destination EVM network — ethereum, arbitrum, base, optimism, polygon, bsc
- dst_token: Destination token address on the EVM chain
- amount: Amount in lamports (1 SOL = 1_000_000_000, 1 USDC = 1_000_000)
- preset: Speed — fast, medium (default), slow"""
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"src_token": {"type": "string", "description": "Source token on Solana"},
"dst_chain": {"type": "string", "description": "Destination EVM network: ethereum, arbitrum, base, optimism, polygon, bsc"},
"dst_token": {"type": "string", "description": "Destination token address on the EVM chain"},
"amount": {"type": "string", "description": "Amount in lamports (1 SOL = 1_000_000_000)"},
"preset": {"type": "string", "description": "Speed preset: fast, medium (default), slow"},
},
"required": ["src_token", "dst_chain", "dst_token", "amount"],
}
async def execute(
self, ctx: ToolContext,
src_token: str = "", dst_chain: str = "",
dst_token: str = "", amount: str = "",
preset: str = "medium", **kwargs,
) -> ToolResult:
if not src_token or not dst_chain or not dst_token or not amount:
return ToolResult(success=False, error="src_token, dst_chain, dst_token, amount are required")
if preset not in ("fast", "medium", "slow"):
return ToolResult(success=False, error=f"Invalid preset '{preset}'. Use: fast, medium, slow")
try:
dst_chain_id = resolve_chain(dst_chain)
except ValueError as e:
return ToolResult(success=False, error=str(e))
sol_wallet = _get_sol_wallet_address()
if not sol_wallet:
return ToolResult(success=False, error="No Solana wallet configured")
evm_wallet = await _get_wallet_address()
if not evm_wallet:
return ToolResult(success=False, error="No EVM wallet configured (needed as receiver)")
try:
# 1. Quote
quote = _fusion_get("/quoter/v1.1/quote/receive", {
"srcChain": str(SOLANA_CHAIN_ID),
"dstChain": str(dst_chain_id),
"srcTokenAddress": src_token,
"dstTokenAddress": dst_token,
"amount": amount,
"walletAddress": sol_wallet,
"enableEstimate": "true",
})
quote_id = quote.get("quoteId", "")
if not quote_id:
return ToolResult(success=False, error="Quote missing quoteId")
dst_amount_est = quote.get("dstTokenAmount", "")
preset_info = quote.get("presets", {}).get(preset, quote.get("presets", {}).get("medium", {}))
secrets_count = preset_info.get("secretsCount", 1)
# 2. Generate secrets
secrets = _generate_secrets(secrets_count)
secret_hashes = [_hash_secret(s) for s in secrets]
# 3. Build Solana order (receiver = EVM wallet)
build_result = _fusion_post(
"/quoter/v1.1/quote/build/solana",
body={"secretsHashList": secret_hashes, "preset": preset, "receiver": evm_wallet},
params={"quoteId": quote_id},
)
order_hash = build_result.get("orderHash", "")
order_struct = build_result.get("order", {})
solana_tx = build_result.get("transaction", "")
if not solana_tx:
return ToolResult(success=False, error="Build API returned no Solana transaction",
output={"build_keys": list(build_result.keys())})
# 4. Sign Solana transaction
from core.wallet_runtime import wallet_request as _wallet_request
sign_result = asyncio.run(_wallet_request("POST", "/agent/sol/sign-transaction", {
"transaction": solana_tx,
}))
signed_tx = sign_result.get("signedTransaction", sign_result.get("transaction", "")) if isinstance(sign_result, dict) else ""
if not signed_tx:
return ToolResult(success=False, error=f"SOL wallet sign failed: {sign_result}")
# 5. Submit
submit_payload = {
"order": order_struct,
"signature": signed_tx,
"quoteId": quote_id,
"srcChainId": SOLANA_CHAIN_ID,
}
if secrets_count > 1:
submit_payload["secretHashes"] = secret_hashes
submit_result = _fusion_post("/relayer/v1.1/submit", submit_payload)
order_hash = submit_result.get("orderHash", order_hash)
if not order_hash:
return ToolResult(success=False, error="Submit returned no order hash")
# 6. Poll
revealed = set()
start = time.time()
while time.time() - start < MAX_POLL_TIME:
if len(revealed) < secrets_count:
try:
fills = _fusion_get(f"/orders/v1.1/order/ready-to-accept-secret-fills/{order_hash}")
for fill in fills.get("fills", []):
idx = fill.get("idx", 0)
if idx not in revealed and idx < len(secrets):
_fusion_post("/relayer/v1.1/submit/secret", {
"orderHash": order_hash,
"secret": "0x" + secrets[idx].hex(),
})
revealed.add(idx)
except Exception:
pass
try:
status = _fusion_get(f"/orders/v1.1/order/status/{order_hash}")
order_status = status.get("status", "").lower()
if order_status in ("executed", "expired", "refunded", "cancelled"):
return ToolResult(
success=(order_status == "executed"),
output={
"status": order_status,
"order_hash": order_hash,
"src_chain": "solana", "dst_chain": dst_chain,
"src_token": src_token, "dst_token": dst_token,
"src_amount": amount,
"dst_amount": status.get("dstAmount", dst_amount_est),
"secrets_revealed": len(revealed),
"elapsed_seconds": int(time.time() - start),
},
error=f"Order {order_status}" if order_status != "executed" else None,
)
except Exception:
pass
time.sleep(POLL_INTERVAL)
return ToolResult(
success=False,
output={
"status": "submitted_polling_timeout",
"order_hash": order_hash,
"src_chain": "solana", "dst_chain": dst_chain,
"src_amount": amount, "dst_amount_estimate": dst_amount_est,
"message": f"Submitted but not confirmed within {MAX_POLL_TIME}s. "
"Use oneinch_cross_chain_status(order_hash) to check later.",
},
error=f"Order timed out after {MAX_POLL_TIME}s.",
)
except Exception as e:
err = str(e)
logger.error(f"SOL→EVM swap failed: {err}", exc_info=True)
if "policy" in err.lower():
return ToolResult(success=False, error=f"Policy violation: {err}. Use wallet_propose_policy.")
return ToolResult(success=False, error=err)
#!/usr/bin/env python3
"""Broadcast ERC20 approve tx for 1inch router (script mode)."""
from __future__ import annotations
import argparse
from _oneinch_lib import (
approve_tx,
build_symbol_index,
compact_token,
fetch_tokens,
print_json,
resolve_chain_id,
resolve_token,
wallet_broadcast,
)
def main() -> None:
p = argparse.ArgumentParser(description="Approve token for 1inch router")
p.add_argument("--chain", required=True)
p.add_argument("--token", required=True, help="Token symbol/address")
p.add_argument("--amount", default="", help="Optional approval amount in wei; empty = unlimited")
args = p.parse_args()
chain_id = resolve_chain_id(args.chain)
token_map = fetch_tokens(chain_id)
idx = build_symbol_index(token_map)
t = resolve_token(args.token, token_map, idx)
tx = approve_tx(chain_id, t["address"], amount=args.amount or None)
resp = wallet_broadcast(chain_id=chain_id, to=tx["to"], data=tx["data"], value=str(tx.get("value", "0")))
out = {
"ok": True,
"action": "approve",
"chain": args.chain,
"chain_id": chain_id,
"token": compact_token(t),
"tx_request": {
"to": tx.get("to"),
"value": str(tx.get("value", "0")),
},
"wallet_response": resp,
}
print_json(out)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Check ERC20 allowance for 1inch router (script mode)."""
from __future__ import annotations
import argparse
from _oneinch_lib import (
NATIVE_TOKEN,
build_symbol_index,
check_allowance,
compact_token,
fetch_tokens,
get_evm_wallet_address,
print_json,
resolve_chain_id,
resolve_token,
)
def main() -> None:
p = argparse.ArgumentParser(description="Check allowance for 1inch")
p.add_argument("--chain", required=True)
p.add_argument("--token", required=True, help="Token symbol/address to check")
p.add_argument("--required", default="0", help="Optional required amount in wei for needs_approval check")
args = p.parse_args()
chain_id = resolve_chain_id(args.chain)
wallet = get_evm_wallet_address()
token_map = fetch_tokens(chain_id)
idx = build_symbol_index(token_map)
t = resolve_token(args.token, token_map, idx)
allowance = check_allowance(chain_id, t["address"], wallet)
required = int(args.required)
out = {
"ok": True,
"action": "check_allowance",
"chain": args.chain,
"chain_id": chain_id,
"wallet": wallet,
"token": compact_token(t),
"allowance": str(allowance),
"needs_approval": (allowance < required) if required > 0 else (allowance == 0 and t["address"].lower() != NATIVE_TOKEN.lower()),
}
print_json(out)
if __name__ == "__main__":
main()
{
"name": "1inch-fusion-builder",
"version": "1.0.0",
"description": "Build 1inch Fusion orders for gasless same-chain swaps",
"main": "build_order.js",
"dependencies": {
"@1inch/byte-utils": "^3.1.0",
"@1inch/fusion-sdk": "2.4.7",
"@1inch/limit-order-sdk": "5.2.4",
"assert": "^2.1.0",
"axios": "^1.15.0",
"ethers": "6.16.0"
}
}
#!/usr/bin/env python3
"""Get a 1inch quote (script mode)."""
from __future__ import annotations
import argparse
from _oneinch_lib import (
build_symbol_index,
compact_token,
fetch_tokens,
from_wei,
print_json,
quote,
resolve_chain_id,
resolve_token,
to_wei,
)
def main() -> None:
p = argparse.ArgumentParser(description="1inch quote")
p.add_argument("--chain", required=True, help="ethereum|arbitrum|base|optimism|polygon|bsc|avalanche|gnosis")
p.add_argument("--from", dest="src", required=True, help="Source token symbol/address, e.g. USDC")
p.add_argument("--to", dest="dst", required=True, help="Destination token symbol/address, e.g. POL")
p.add_argument("--amount", required=True, help="Human amount in source token units, e.g. 1")
args = p.parse_args()
chain_id = resolve_chain_id(args.chain)
token_map = fetch_tokens(chain_id)
idx = build_symbol_index(token_map)
src = resolve_token(args.src, token_map, idx)
dst = resolve_token(args.dst, token_map, idx)
src_dec = int(src.get("decimals", 18))
dst_dec = int(dst.get("decimals", 18))
amount_wei = to_wei(args.amount, src_dec)
q = quote(chain_id, src["address"], dst["address"], amount_wei)
out = {
"ok": True,
"action": "quote",
"chain": args.chain,
"chain_id": chain_id,
"src": compact_token(src),
"dst": compact_token(dst),
"amount_in_human": args.amount,
"amount_in_wei": amount_wei,
"estimated_out_wei": q.get("dstAmount"),
"estimated_out_human": from_wei(str(q.get("dstAmount", "0")), dst_dec),
"gas": q.get("gas"),
"protocols": q.get("protocols"),
}
print_json(out)
if __name__ == "__main__":
main()