Skill: d5c5ceb0/gstable-ai-payment (version 1.0.0)

--- name: gstable-ai-payment description: "GStable AI Payment Protocol - enables AI Agents to discover, negotiate, and execute cryptocurrency payments on behalf of users" metadata: {"openclaw":{"emoji":"💰","homepage":"https://docs.gstable.io/zh-Hans/docs/category/ai-payment-protocol","primaryEnv":"WALLET_PRIVATE_KEY","requires":{"env":["WALLET_PRIVATE_KEY"]},"install":[{"id":"uv-install","kind":"shell","command":"uv sync","label":"Install gstable-ai-payment CLI (uv)"}]},"clawdbot":{"emoji":"💰","homepage":"https://docs.gstable.io/zh-Hans/docs/category/ai-payment-protocol","primaryEnv":"WALLET_PRIVATE_KEY","requires":{"env":["WALLET_PRIVATE_KEY"]},"install":[{"id":"uv-install","kind":"shell","command":"uv sync","label":"Install gstable-ai-payment CLI (uv)"}]}} --- # GStable AI Payment Skill An OpenClaw skill that enables AI Agents to discover, negotiate, and execute cryptocurrency payments on behalf of users. ## Features - 🔗 Retrieve payment link details and supported tokens - 📝 Create payment sessions (EIP-712 signatures) - 🔍 Query payment session status - 💰 Check native and ERC20 balances - 💳 Prepare payments and generate on-chain transaction calldata - ✅ Check and automatically approve tokens - ⚡ Execute on-chain payment transactions - 🚀 One-command payment (`pay`) with automatic approval handling - 🔐 Secure EIP-712 signing (private key stored in environment variables) - ⛓️ Multi-chain support (Polygon, Ethereum, Arbitrum, Base) ## Installation ```bash clawhub install gstable-ai-payment cd ~/.openclaw/workspace/skills/gstable-ai-payment uv sync ``` ## Configuration Set environment variables: ```bash # Required: wallet private key used to sign EIP-712 messages and send transactions export WALLET_PRIVATE_KEY=0x...your_private_key_here... # Optional: GStable API base URL (default: https://aipay.gstable.io/api/v1) export GSTABLE_API_BASE_URL=https://aipay.gstable.io/api/v1 # Optional: default payer email export DEFAULT_PAYER_EMAIL=[REDACTED_SECRET] ``` ⚠️ **Security note**: Never commit private keys to version control. ## Quick Start ### 1. Get payment link details ```bash # Payment link formats: # - https://pay.gstable.io/link/<link_id> # - https://aipay.gstable.io/api/v1/payment/link/<link_id> # Example 1 uv run scripts/gstable-ai-payment.py get_link lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua # Example 2 uv run scripts/gstable-ai-payment.py get_link lnk_QTAfGfyqAZHGSm9NKLhtjNYu8dNHRpGh ``` ### 2. Create payment session ```bash uv run scripts/gstable-ai-payment.py create_session lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua 137 USDC ``` ### 3. One-command payment (recommended) ```bash uv run scripts/gstable-ai-payment.py pay lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua 137 USDC [REDACTED_SECRET] ``` ## All Commands ```bash # Get payment link details uv run scripts/gstable-ai-payment.py get_link <link_id> # Create payment session uv run scripts/gstable-ai-payment.py create_session <link_id> <chain_id> <token> [payer] uv run scripts/gstable-ai-payment.py create_session lnk_xxx 137 USDC uv run scripts/gstable-ai-payment.py create_session lnk_xxx 137 0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 # Get session status uv run scripts/gstable-ai-payment.py get_session <session_id> # Check native/ERC20 balance uv run scripts/gstable-ai-payment.py balance <chain_id> [token_address] [wallet] # Prepare payment (generate calldata) uv run scripts/gstable-ai-payment.py prepare <session_id> <chain_id> <token_address> [email] # Execute on-chain transaction uv run scripts/gstable-ai-payment.py execute <chain_id> <to_address> <calldata> # Check token allowance uv run scripts/gstable-ai-payment.py allowance <chain_id> <token_address> <spender> # Approve token for payment contract uv run scripts/gstable-ai-payment.py approve <chain_id> <token_address> <spender> [amount] # One-command payment (full flow, automatic approval) uv run scripts/gstable-ai-payment.py pay <link_id> <chain_id> <token> [email] # Show wallet address uv run scripts/gstable-ai-payment.py wallet ``` ## Supported Chains | Chain | Chain ID | Tokens | |-------|----------|------| | Polygon | 137 | USDC, USDT | | Ethereum | 1 | USDC, USDT | | Arbitrum | 42161 | USDC | | Base | 8453 | USDC | Use `uv run scripts/gstable-ai-payment.py get_link <link-id>` to see exactly which chains and tokens are supported for a specific payment link. ## Usage Examples ### Complete payment flow ```bash # Payment link: https://pay.gstable.io/link/lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua # Option 1: one-command payment (recommended) uv run scripts/gstable-ai-payment.py pay lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua 137 USDC [REDACTED_SECRET] # Output: # Step 1/5: Getting payment link details... # Step 2/5: Creating payment session... # Step 3/5: Preparing payment... # Step 4/5: Checking token allowance... # Step 5/5: Executing on-chain payment transaction... # ✅ Payment completed! # { "linkId": [REDACTED_SECRET], "sessionId": "sess_xxx", "txHash": "0x..." } # Option 2: run step by step # 1) Get payment link details uv run scripts/gstable-ai-payment.py get_link lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua # Returns payment link details in JSON format # 2) Create payment session uv run scripts/gstable-ai-payment.py create_session lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua 137 USDC # Output: { "sessionId": "sess_abc123", ... } # 3) Prepare payment uv run scripts/gstable-ai-payment.py prepare sess_abc123 137 0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 [REDACTED_SECRET] # Output: { "executionChainId": "137", "executorContract": "0x...", "calldata": "0x..." } # 4) Check and approve allowance (if needed) uv run scripts/gstable-ai-payment.py allowance 137 0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 0x... uv run scripts/gstable-ai-payment.py approve 137 0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 0x... # 5) Execute on-chain transaction uv run scripts/gstable-ai-payment.py execute 137 0x... 0x... # Output: { "status": "submitted", "txHash": "0x..." } ``` ### Agent interaction example ``` User: "I want to pay this: https://pay.gstable.io/link/lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua" Agent: [uv run scripts/gstable-ai-payment.py get_link lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua] "This is a payment link. You can pay with USDC on Polygon. Which network would you like to use?" User: "Use Polygon" Agent: [uv run scripts/gstable-ai-payment.py pay lnk_BUDBgiGTWejFs8v0FbdpR3iJ83CG1tua 137 USDC] "✅ Payment completed! Transaction hash: 0x..." ``` ## Payment Flow ``` ┌─────────────────┐ │ User shares link│ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ get_link │ ──► Get product and payment options └────────┬────────┘ │ ▼ ┌─────────────────┐ │ User picks token│ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ create_session │ ──► Create session (EIP-712 signature) └────────┬────────┘ │ ▼ ┌─────────────────┐ │ prepare │ ──► Get transaction calldata (EIP-712 signature) └────────┬────────┘ │ ▼ ┌─────────────────┐ │ allowance │ ──► Check token allowance └────────┬────────┘ │ ▼ (if insufficient) ┌─────────────────┐ │ approve │ ──► Approve token for payment contract └────────┬────────┘ │ ▼ ┌─────────────────┐ │ execute │ ──► Send on-chain transaction └────────┬────────┘ │ ▼ ┌─────────────────┐ │ ✅ Payment done │ └─────────────────┘ Or use the `pay` command to complete everything in one step. ``` ## Environment Variables | Variable | Required | Description | |------|------|------| | `WALLET_PRIVATE_KEY` | ✅ | Wallet private key used to sign EIP-712 messages and execute transactions (`0x` prefix) | | `GSTABLE_API_BASE_URL` | ❌ | GStable API base URL (default: https://aipay.gstable.io/api/v1) | | `DEFAULT_PAYER_EMAIL` | ❌ | Default payer email | | `RPC_URL_POLYGON` | ❌ | Polygon RPC URL (default: https://polygon-rpc.com) | | `RPC_URL_ETHEREUM` | ❌ | Ethereum RPC URL (default: https://eth.llamarpc.com) | | `RPC_URL_ARBITRUM` | ❌ | Arbitrum RPC URL (default: https://arb1.arbitrum.io/rpc) | | `RPC_URL_BASE` | ❌ | Base RPC URL (default: https://mainnet.base.org) | ## Troubleshooting **"WALLET_PRIVATE_KEY not set"** ```bash export WALLET_PRIVATE_KEY=0x... ``` **"Token not supported"** ```bash # Check supported tokens first uv run scripts/gstable-ai-payment.py get_link <link_id> ``` **"Session expired"** ```bash # Recreate session uv run scripts/gstable-ai-payment.py create_session <link_id> <chain_id> <token> ``` **"No RPC URL configured for chain"** ```bash # Set RPC URL for the corresponding chain export RPC_URL_POLYGON=https://polygon-rpc.com ``` **"Gas estimation failed" or "Transaction failed"** - Ensure the wallet has enough native token (e.g., MATIC) to pay gas fees - Ensure the wallet has enough token balance to complete the payment - Check whether the token has been approved for the payment contract ## Resources - GStable AI Payment Protocol: https://docs.gstable.io/zh-Hans/docs/category/ai-payment-protocol - GStable AI Agent Integration: https://docs.gstable.io/zh-Hans/docs/category/ai-agent-integration - GitHub: https://github.com/gstable/gstable-ai-payment-skill ## License MIT

path: pyproject.toml
content: [project]
name = "gstable-ai-payment"
version = "1.0.0"
description = "GStable AI Payment Protocol Skill for OpenClaw"
readme = "SKILL.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
    "eth-account>=0.13.0",
    "httpx>=0.27.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["scripts"]

[dependency-groups]
dev = [
    "pytest>=8.0.0",
]


path: scripts/config.py
content: """
Configuration management for GStable AI Payment Skill
Reads from environment variables
"""

import os
from dataclasses import dataclass
from typing import Optional

_config_instance: Optional["Config"] = None


@dataclass
class Config:
    """Configuration for GStable AI Payment"""
    wallet_private_key: str
    api_base_url: str
    default_payer_email: str
    rpc_urls: dict  # chain_id -> rpc_url


# Default RPC URLs
DEFAULT_RPC_URLS = {
    "137": "https://polygon-bor.publicnode.com",
    "1": "https://eth.llamarpc.com",
    "42161": "https://arb1.arbitrum.io/rpc",
    "8453": "https://mainnet.base.org",
}

CHAIN_ID_TO_ENV_NAME = {
    "137": "POLYGON",
    "1": "ETHEREUM",
    "42161": "ARBITRUM",
    "8453": "BASE",
}


def load_config() -> Config:
    """
    Load configuration from environment variables
    
    Returns:
        Config: Configuration object
        
    Raises:
        ValueError: If required environment variables are not set
    """
    wallet_private_key = os.environ.get("WALLET_PRIVATE_KEY")
    
    if not wallet_private_key:
        raise ValueError(
            "WALLET_PRIVATE_KEY environment variable is required. "
            "Please set it to your wallet private key (0x... format)."
        )
    
    # Validate private key format
    if not wallet_private_key.startswith("0x") or len(wallet_private_key) != 66:
        raise ValueError(
            "WALLET_PRIVATE_KEY must be a valid hex string starting with 0x (66 characters total)."
        )
    
    rpc_urls = {**DEFAULT_RPC_URLS}

    # Preferred format: chain-name based RPC env vars, e.g. RPC_URL_POLYGON
    for chain_id, chain_name in CHAIN_ID_TO_ENV_NAME.items():
        env_key = f"RPC_URL_{chain_name}"
        if os.environ.get(env_key):
            rpc_urls[chain_id] = os.environ[env_key]

    # Backward compatibility: allow numeric env vars, e.g. RPC_URL_137
    for chain_id in CHAIN_ID_TO_ENV_NAME.keys():
        legacy_env_key = f"RPC_URL_{chain_id}"
        if os.environ.get(legacy_env_key):
            rpc_urls[chain_id] = os.environ[legacy_env_key]

    return Config(
        wallet_private_key=wallet_private_key,
        api_base_url=os.environ.get("GSTABLE_API_BASE_URL", "https://aipay.gstable.io/api/v1"),
        default_payer_email=os.environ.get("DEFAULT_PAYER_EMAIL", "[REDACTED_SECRET]"),
        rpc_urls=rpc_urls,
    )


def get_config() -> Config:
    """
    Get or create singleton config instance
    
    Returns:
        Config: Configuration object
    """
    global _config_instance
    if _config_instance is None:
        _config_instance = load_config()
    return _config_instance


path: scripts/eip712.py
content: """
EIP-712 Message Construction Helpers
"""

import secrets
import time
from dataclasses import dataclass
from typing import Any


def generate_nonce() -> str:
    """
    Generate a random 32-byte nonce for authorization
    
    Returns:
        str: Hex string starting with 0x
    """
    return "0x" + secrets.token_hex(32)


def generate_expires_at(duration_seconds: int = 3600) -> str:
    """
    Generate expiration timestamp
    
    Args:
        duration_seconds: Duration in seconds (default: 1 hour)
        
    Returns:
        str: Unix timestamp as string
    """
    return str(int(time.time()) + duration_seconds)


def get_eip712_domain(chain_id: int | str) -> dict[str, Any]:
    """
    EIP-712 Domain for GStable AI Payment
    
    Args:
        chain_id: Chain ID
        
    Returns:
        dict: EIP-712 domain object
    """
    return {
        "name": "aipay.gstable.io",
        "version": "1",
        "chainId": int(chain_id),
    }


# EIP-712 Types for PaymentSessionCreationAuthorization
PAYMENT_SESSION_CREATION_TYPES = {
    "PaymentSessionCreationAuthorization": [
        {"name": "purpose", "type": "string"},
        {"name": "linkId", "type": "string"},
        {"name": "linkVersion", "type": "string"},
        {"name": "merchantId", "type": "string"},
        {"name": "payer", "type": "address"},
        {"name": "paymentChainId", "type": "uint256"},
        {"name": "paymentToken", "type": "address"},
        {"name": "amount", "type": "uint256"},
        {"name": "authorizationNonce", "type": "bytes32"},
        {"name": "authorizationExpiresAt", "type": "uint256"},
    ]
}

# EIP-712 Types for PreparePaymentAuthorization
PREPARE_PAYMENT_TYPES = {
    "PreparePaymentAuthorization": [
        {"name": "purpose", "type": "string"},
        {"name": "sessionId", "type": "string"},
        {"name": "merchantId", "type": "string"},
        {"name": "paymentChainId", "type": "uint256"},
        {"name": "paymentToken", "type": "address"},
        {"name": "payer", "type": "address"},
        {"name": "payerEmail", "type": "string"},
        {"name": "authorizationNonce", "type": "bytes32"},
        {"name": "authorizationExpiresAt", "type": "uint256"},
    ]
}


@dataclass
class PaymentSessionCreationAuthorizationMessage:
    """Helper class for constructing PaymentSessionCreationAuthorization messages"""
    
    link_id: str
    link_version: str
    merchant_id: str
    payer: str
    payment_chain_id: int
    payment_token: str
    amount: int
    authorization_nonce: str
    authorization_expires_at: int
    
    @property
    def purpose(self) -> str:
        return "create_payment_session"
    
    def to_message(self) -> dict[str, Any]:
        """
        Convert to message dict for API
        
        Returns:
            dict: Message object
        """
        return {
            "purpose": self.purpose,
            "linkId": self.link_id,
            "linkVersion": self.link_version,
            "merchantId": self.merchant_id,
            "payer": self.payer,
            "paymentChainId": str(self.payment_chain_id),
            "paymentToken": self.payment_token,
            "amount": str(self.amount),
            "authorizationNonce": self.authorization_nonce,
            "authorizationExpiresAt": str(self.authorization_expires_at),
        }
    
    def to_signable_message(self) -> dict[str, Any]:
        """
        Convert to signable message for EIP-712
        
        Returns:
            dict: Signable message object with correct types
        """
        return {
            "purpose": self.purpose,
            "linkId": self.link_id,
            "linkVersion": self.link_version,
            "merchantId": self.merchant_id,
            "payer": self.payer,
            "paymentChainId": self.payment_chain_id,
            "paymentToken": self.payment_token,
            "amount": self.amount,
            "authorizationNonce": bytes.fromhex(self.authorization_nonce[2:]),
            "authorizationExpiresAt": self.authorization_expires_at,
        }


@dataclass
class PreparePaymentAuthorizationMessage:
    """Helper class for constructing PreparePaymentAuthorization messages"""
    
    session_id: str
    merchant_id: str
    payer: str
    payer_email: str
    payment_chain_id: int
    payment_token: str
    authorization_nonce: str
    authorization_expires_at: int
    
    @property
    def purpose(self) -> str:
        return "prepare_payment"
    
    def to_message(self) -> dict[str, Any]:
        """
        Convert to message dict for API
        
        Returns:
            dict: Message object
        """
        return {
            "purpose": self.purpose,
            "sessionId": self.session_id,
            "merchantId": self.merchant_id,
            "payer": self.payer,
            "payerEmail": self.payer_email,
            "paymentChainId": str(self.payment_chain_id),
            "paymentToken": self.payment_token,
            "authorizationNonce": self.authorization_nonce,
            "authorizationExpiresAt": str(self.authorization_expires_at),
        }
    
    def to_signable_message(self) -> dict[str, Any]:
        """
        Convert to signable message for EIP-712
        
        Returns:
            dict: Signable message object with correct types
        """
        return {
            "purpose": self.purpose,
            "sessionId": self.session_id,
            "merchantId": self.merchant_id,
            "paymentChainId": self.payment_chain_id,
            "paymentToken": self.payment_token,
            "payer": self.payer,
            "payerEmail": self.payer_email,
            "authorizationNonce": bytes.fromhex(self.authorization_nonce[2:]),
            "authorizationExpiresAt": self.authorization_expires_at,
        }


path: scripts/gstable-ai-payment.py
content: #!/usr/bin/env python3
"""
GStable AI Payment Skill - Unified Entry Point

Usage:
    # Run directly
    uv run python scripts/gstable-ai-payment.py get_link lnk_xxx
    uv run python scripts/gstable-ai-payment.py create_session lnk_xxx 137 USDC
    uv run python scripts/gstable-ai-payment.py prepare sess_xxx 137 0x3c499c...
    uv run python scripts/gstable-ai-payment.py execute 137 0x614C7B... 0x93fec3f7...
    uv run python scripts/gstable-ai-payment.py wallet

Supported commands:
    get_link <link_id>                                  Get payment link details
    create_session <link_id> <chain_id> <token>         Create payment session
    get_session <session_id>                            Get session status
    prepare <session_id> <chain_id> <token> [email]     Prepare payment
    balance <chain_id> [token_address] [wallet]         Check native/ERC20 balance
    execute <chain_id> <to_address> <calldata>          Execute on-chain payment transaction
    allowance <chain_id> <token> <spender>              Check token allowance
    approve <chain_id> <token> <spender> [amount]       Approve token for payment contract
    pay <link_id> <chain_id> <token> [email]            One-command payment (full flow)
    wallet                                              Show wallet address
"""

import sys
import os
import json

# Ensure current directory is in the Python path
sys.path.insert(0, os.path.dirname(__file__))

from config import get_config
from signer import get_wallet_address, sign_create_session_message, sign_prepare_payment_message
from eip712 import (
    generate_nonce,
    generate_expires_at,
    PaymentSessionCreationAuthorizationMessage,
    PreparePaymentAuthorizationMessage,
)
from api import (
    get_payment_link_sync,
    create_payment_session_sync,
    get_payment_session_sync,
    prepare_payment_sync,
)


def cmd_get_link(link_id: str) -> dict:
    """Get payment link details"""
    link_data = get_payment_link_sync(link_id)
    
    tokens = link_data["aiView"]["supportedPaymentTokens"]
    items = link_data["lineItems"]
    
    result = {
        "linkName": link_data["linkName"],
        "linkId": link_data["linkId"],
        "linkVersion": link_data["linkVersion"],
        "merchantId": link_data["merchantId"],
        "items": [
            {
                "name": item["productData"]["productName"],
                "price": item["unitPriceInUSD"],
                "quantity": item["quantity"],
            }
            for item in items
        ],
        "supportedTokens": [
            {
                "symbol": t["symbol"],
                "chainName": t["chainName"],
                "chainId": t["chainId"],
                "tokenAddress": t["tokenAddress"],
                "amountInUSD": t["amountInUSD"],
                "amountInToken": t["amountInToken"],  # Actual payment amount
            }
            for t in tokens
        ],
    }
    
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return result


def cmd_create_session(link_id: str, chain_id: str, token: str, payer: str = None) -> dict:
    """Create payment session"""
    link_data = get_payment_link_sync(link_id)
    
    # Find matching token
    selected_token = None
    for t in link_data["aiView"]["supportedPaymentTokens"]:
        if t["chainId"] == chain_id and (
            t["tokenAddress"].lower() == token.lower() or
            t["symbol"].lower() == token.lower()
        ):
            selected_token = t
            break
    
    if not selected_token:
        raise Exception(f"Token {token} on chain {chain_id} is not supported.")
    
    payer_address = payer or get_wallet_address()
    nonce = generate_nonce()
    expires_at = generate_expires_at(3600)
    
    auth_msg = PaymentSessionCreationAuthorizationMessage(
        link_id=link_data["linkId"],
        link_version=link_data["linkVersion"],
        merchant_id=link_data["merchantId"],
        payer=payer_address,
        payment_chain_id=int(selected_token["chainId"]),
        payment_token=selected_token["tokenAddress"],
        amount=int(selected_token["amountInToken"]),
        authorization_nonce=nonce,
        authorization_expires_at=int(expires_at),
    )
    
    signature = sign_create_session_message(auth_msg, selected_token["chainId"])
    session_data = create_payment_session_sync(auth_msg.to_message(), signature)
    
    result = {
        "sessionId": session_data["sessionId"],
        "sessionType": session_data["sessionType"],
        "expiresAt": session_data["sessionExpiresAt"],
        "nextStep": session_data["aiView"]["workflow"]["nextAction"]["action"],
        "chainId": selected_token["chainId"],
        "tokenAddress": selected_token["tokenAddress"],
    }
    
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return result


def cmd_get_session(session_id: str) -> dict:
    """Get session status"""
    session_data = get_payment_session_sync(session_id)
    
    workflow = session_data["aiView"]["workflow"]
    
    result = {
        "sessionId": session_data["sessionId"],
        "sessionType": session_data["sessionType"],
        "stage": workflow["stage"],
        "nextAction": workflow["nextAction"]["action"],
        "description": workflow["nextAction"].get("description"),
    }
    
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return result


def cmd_prepare(session_id: str, chain_id: str, token: str, email: str = None) -> dict:
    """Prepare payment"""
    session_data = get_payment_session_sync(session_id)
    merchant_id = session_data.get("merchantId")
    
    if not merchant_id:
        raise Exception("Could not get merchant ID from session.")
    
    config = get_config()
    payer_email = email or config.default_payer_email
    payer_address = get_wallet_address()
    payment_chain_id = int(chain_id)
    
    nonce = generate_nonce()
    expires_at = generate_expires_at(3600)
    
    auth_msg = PreparePaymentAuthorizationMessage(
        session_id=session_id,
        merchant_id=merchant_id,
        payer=payer_address,
        payer_email=payer_email,
        payment_chain_id=payment_chain_id,
        payment_token=token,
        authorization_nonce=nonce,
        authorization_expires_at=int(expires_at),
    )
    
    signature = sign_prepare_payment_message(auth_msg, payment_chain_id)
    prepare_data = prepare_payment_sync(auth_msg.to_message(), signature)
    
    result = {
        "executionChainId": prepare_data.get("executionChainId"),
        "executorContract": prepare_data.get("executorContract"),
        "calldata": prepare_data.get("calldata"),
        "stage": prepare_data["aiView"]["workflow"]["stage"],
        "nextAction": prepare_data["aiView"]["workflow"]["nextAction"]["action"],
    }
    
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return result


def cmd_wallet() -> dict:
    """Show wallet address"""
    address = get_wallet_address()
    result = {"address": address}
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return result


def cmd_execute(chain_id: str, to_address: str, calldata: str) -> dict:
    """Execute on-chain payment transaction"""
    from eth_account import Account
    import httpx
    
    config = get_config()
    
    # Get RPC URL
    rpc_url = config.rpc_urls.get(chain_id)
    if not rpc_url:
        raise Exception(
            f"No RPC URL configured for chain {chain_id}. "
            "Set chain-name env vars (preferred): "
            "RPC_URL_POLYGON / RPC_URL_ETHEREUM / RPC_URL_ARBITRUM / RPC_URL_BASE. "
            f"Legacy RPC_URL_{chain_id} is also supported."
        )
    
    # Create account
    account = Account.from_key(config.wallet_private_key)
    
    # Get nonce
    with httpx.Client() as client:
        # Get nonce
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_getTransactionCount",
            "params": [account.address, "latest"],
            "id": 1,
        })
        nonce = int(resp.json()["result"], 16)
        
        # Get gas price
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_gasPrice",
            "params": [],
            "id": 2,
        })
        gas_price = int(resp.json()["result"], 16)
        
        # Estimate gas
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_estimateGas",
            "params": [{
                "from": account.address,
                "to": to_address,
                "data": calldata,
                "value": "0x0",
            }],
            "id": 3,
        })
        gas_result = resp.json()
        if "error" in gas_result:
            raise Exception(f"Gas estimation failed: {gas_result['error']}")
        gas_limit = int(gas_result["result"], 16)
        
        # Build transaction
        tx = {
            "nonce": nonce,
            "gasPrice": gas_price,
            "gas": int(gas_limit * 1.2),  # Add 20% buffer
            "to": to_address,
            "value": 0,
            "data": bytes.fromhex(calldata[2:]) if calldata.startswith("0x") else bytes.fromhex(calldata),
            "chainId": int(chain_id),
        }
        
        # Sign transaction
        signed_tx = account.sign_transaction(tx)
        
        # Send transaction
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_sendRawTransaction",
            "params": ["0x" + signed_tx.raw_transaction.hex()],
            "id": 4,
        })
        
        send_result = resp.json()
        if "error" in send_result:
            raise Exception(f"Transaction failed: {send_result['error']}")
        
        tx_hash = send_result["result"]
        
        result = {
            "status": "submitted",
            "txHash": tx_hash,
            "chainId": chain_id,
            "from": account.address,
            "to": to_address,
            "gasLimit": gas_limit,
            "gasPrice": gas_price,
        }
        
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return result


# ERC20 ABI fragments
ERC20_BALANCE_OF_SIG = "0x70a08231"  # balanceOf(address)
ERC20_ALLOWANCE_SIG = "0xdd62ed3e"  # allowance(address,address)
ERC20_APPROVE_SIG = "0x095ea7b3"    # approve(address,uint256)
MAX_UINT256 = 2**256 - 1


def cmd_balance(chain_id: str, token_address: str = None, wallet: str = None) -> dict:
    """Check native or ERC20 token balance"""
    import httpx

    config = get_config()
    rpc_url = config.rpc_urls.get(chain_id)
    if not rpc_url:
        raise Exception(
            f"No RPC URL configured for chain {chain_id}. "
            "Set chain-name env vars (preferred): "
            "RPC_URL_POLYGON / RPC_URL_ETHEREUM / RPC_URL_ARBITRUM / RPC_URL_BASE. "
            f"Legacy RPC_URL_{chain_id} is also supported."
        )

    target_wallet = wallet or get_wallet_address()

    with httpx.Client() as client:
        if token_address:
            # ERC20 balanceOf(wallet)
            owner_padded = target_wallet[2:].lower().zfill(64)
            calldata = ERC20_BALANCE_OF_SIG + owner_padded

            resp = client.post(rpc_url, json={
                "jsonrpc": "2.0",
                "method": "eth_call",
                "params": [{
                    "to": token_address,
                    "data": calldata,
                }, "latest"],
                "id": 1,
            })
            result_data = resp.json()
            if "error" in result_data:
                raise Exception(f"Failed to check token balance: {result_data['error']}")

            balance_hex = result_data["result"]
            balance = int(balance_hex, 16)

            result = {
                "chainId": chain_id,
                "wallet": target_wallet,
                "assetType": "erc20",
                "token": token_address,
                "balance": str(balance),
                "balanceHex": balance_hex,
            }
        else:
            # Native balance (ETH/MATIC/...)
            resp = client.post(rpc_url, json={
                "jsonrpc": "2.0",
                "method": "eth_getBalance",
                "params": [target_wallet, "latest"],
                "id": 1,
            })
            result_data = resp.json()
            if "error" in result_data:
                raise Exception(f"Failed to check native balance: {result_data['error']}")

            balance_hex = result_data["result"]
            balance = int(balance_hex, 16)

            result = {
                "chainId": chain_id,
                "wallet": target_wallet,
                "assetType": "native",
                "balance": str(balance),
                "balanceHex": balance_hex,
            }

        print(json.dumps(result, indent=2, ensure_ascii=False))
        return result


def cmd_allowance(chain_id: str, token_address: str, spender: str) -> dict:
    """Check token allowance"""
    import httpx
    
    config = get_config()
    rpc_url = config.rpc_urls.get(chain_id)
    if not rpc_url:
        raise Exception(
            f"No RPC URL configured for chain {chain_id}. "
            "Set chain-name env vars (preferred): "
            "RPC_URL_POLYGON / RPC_URL_ETHEREUM / RPC_URL_ARBITRUM / RPC_URL_BASE. "
            f"Legacy RPC_URL_{chain_id} is also supported."
        )
    
    owner = get_wallet_address()
    
    # allowance(owner, spender)
    owner_padded = owner[2:].lower().zfill(64)
    spender_padded = spender[2:].lower().zfill(64)
    calldata = ERC20_ALLOWANCE_SIG + owner_padded + spender_padded
    
    with httpx.Client() as client:
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_call",
            "params": [{
                "to": token_address,
                "data": calldata,
            }, "latest"],
            "id": 1,
        })
        result_data = resp.json()
        if "error" in result_data:
            raise Exception(f"Failed to check allowance: {result_data['error']}")
        
        allowance_hex = result_data["result"]
        allowance = int(allowance_hex, 16)
        
        result = {
            "owner": owner,
            "spender": spender,
            "token": token_address,
            "chainId": chain_id,
            "allowance": str(allowance),
            "allowanceHex": allowance_hex,
        }
        
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return result


def cmd_approve(chain_id: str, token_address: str, spender: str, amount: str = None) -> dict:
    """Approve token for payment contract"""
    from eth_account import Account
    import httpx
    
    config = get_config()
    rpc_url = config.rpc_urls.get(chain_id)
    if not rpc_url:
        raise Exception(
            f"No RPC URL configured for chain {chain_id}. "
            "Set chain-name env vars (preferred): "
            "RPC_URL_POLYGON / RPC_URL_ETHEREUM / RPC_URL_ARBITRUM / RPC_URL_BASE. "
            f"Legacy RPC_URL_{chain_id} is also supported."
        )
    
    account = Account.from_key(config.wallet_private_key)
    
    # Use provided amount or default to max value (infinite approval)
    if amount:
        approve_amount = int(amount)
    else:
        approve_amount = MAX_UINT256
    
    # approve(spender, amount)
    spender_padded = spender[2:].lower().zfill(64)
    amount_padded = hex(approve_amount)[2:].zfill(64)
    calldata = ERC20_APPROVE_SIG + spender_padded + amount_padded
    
    with httpx.Client() as client:
        # Get nonce
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_getTransactionCount",
            "params": [account.address, "latest"],
            "id": 1,
        })
        nonce = int(resp.json()["result"], 16)
        
        # Get gas price
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_gasPrice",
            "params": [],
            "id": 2,
        })
        gas_price = int(resp.json()["result"], 16)
        
        # Estimate gas
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_estimateGas",
            "params": [{
                "from": account.address,
                "to": token_address,
                "data": calldata,
            }],
            "id": 3,
        })
        gas_result = resp.json()
        if "error" in gas_result:
            raise Exception(f"Gas estimation failed: {gas_result['error']}")
        gas_limit = int(gas_result["result"], 16)
        
        # Build and sign transaction
        tx = {
            "nonce": nonce,
            "gasPrice": gas_price,
            "gas": int(gas_limit * 1.2),
            "to": token_address,
            "value": 0,
            "data": bytes.fromhex(calldata[2:]),
            "chainId": int(chain_id),
        }
        
        signed_tx = account.sign_transaction(tx)
        
        # Send transaction
        resp = client.post(rpc_url, json={
            "jsonrpc": "2.0",
            "method": "eth_sendRawTransaction",
            "params": ["0x" + signed_tx.raw_transaction.hex()],
            "id": 4,
        })
        
        send_result = resp.json()
        if "error" in send_result:
            raise Exception(f"Approve transaction failed: {send_result['error']}")
        
        tx_hash = send_result["result"]
        
        result = {
            "status": "approved",
            "txHash": tx_hash,
            "token": token_address,
            "spender": spender,
            "amount": str(approve_amount),
            "chainId": chain_id,
        }
        
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return result


def _wait_for_tx(rpc_url: str, tx_hash: str, timeout: int = 60) -> bool:
    """Wait for transaction confirmation"""
    import httpx
    import time
    
    start_time = time.time()
    with httpx.Client() as client:
        while time.time() - start_time < timeout:
            resp = client.post(rpc_url, json={
                "jsonrpc": "2.0",
                "method": "eth_getTransactionReceipt",
                "params": [tx_hash],
                "id": 1,
            })
            result = resp.json()
            if result.get("result"):
                receipt = result["result"]
                return int(receipt["status"], 16) == 1
            time.sleep(2)
    return False


def cmd_pay(link_id: str, chain_id: str, token: str, email: str = None) -> dict:
    """One-command payment - full flow (with automatic approval)"""
    config = get_config()
    
    print("Step 1/5: Getting payment link details...")
    link_data = cmd_get_link(link_id)
    
    print("\nStep 2/5: Creating payment session...")
    session_data = cmd_create_session(link_id, chain_id, token)
    session_id = session_data["sessionId"]
    token_address = session_data["tokenAddress"]
    
    print("\nStep 3/5: Preparing payment...")
    prepare_data = cmd_prepare(session_id, chain_id, token_address, email)
    
    executor_contract = prepare_data["executorContract"]
    execution_chain_id = prepare_data["executionChainId"]
    
    # Step 4: Check and execute approve (if needed)
    print("\nStep 4/5: Checking token allowance...")
    allowance_data = cmd_allowance(execution_chain_id, token_address, executor_contract)
    current_allowance = int(allowance_data["allowance"])
    
    # Get the actual required amount from the payment link
    required_amount = 0
    for t in link_data["supportedTokens"]:
        if t["tokenAddress"].lower() == token_address.lower() and t["chainId"] == chain_id:
            required_amount = int(t["amountInToken"])  # Use actual payment amount
            break
    
    # Keep 10% headroom just in case
    required_with_buffer = int(required_amount * 1.1)
    
    if current_allowance < required_with_buffer:
        print(f"\nInsufficient allowance. Approving token...")
        approve_result = cmd_approve(execution_chain_id, token_address, executor_contract)
        
        # Wait for approval transaction confirmation
        rpc_url = config.rpc_urls.get(execution_chain_id)
        print("Waiting for approval transaction confirmation...")
        if not _wait_for_tx(rpc_url, approve_result["txHash"]):
            raise Exception("Approve transaction failed or timed out")
        print("✅ Approval successful!")
    else:
        print("✅ Token is already approved")
    
    print("\nStep 5/5: Executing on-chain payment transaction...")
    execute_result = cmd_execute(
        execution_chain_id,
        executor_contract,
        prepare_data["calldata"],
    )
    
    print("\n✅ Payment completed!")
    return {
        "linkId": link_id,
        "sessionId": session_id,
        "txHash": execute_result["txHash"],
        "chainId": execute_result["chainId"],
    }


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    
    command = sys.argv[1]
    args = sys.argv[2:]
    
    try:
        if command == "get_link":
            if len(args) < 1:
                print("Usage: get_link <link_id>")
                sys.exit(1)
            cmd_get_link(args[0])
        
        elif command == "create_session":
            if len(args) < 3:
                print("Usage: create_session <link_id> <chain_id> <token> [payer]")
                sys.exit(1)
            payer = args[3] if len(args) > 3 else None
            cmd_create_session(args[0], args[1], args[2], payer)
        
        elif command == "get_session":
            if len(args) < 1:
                print("Usage: get_session <session_id>")
                sys.exit(1)
            cmd_get_session(args[0])
        
        elif command == "prepare":
            if len(args) < 3:
                print("Usage: prepare <session_id> <chain_id> <token> [email]")
                sys.exit(1)
            email = args[3] if len(args) > 3 else None
            cmd_prepare(args[0], args[1], args[2], email)
        
        elif command == "wallet":
            cmd_wallet()

        elif command == "balance":
            if len(args) < 1:
                print("Usage: balance <chain_id> [token_address] [wallet]")
                sys.exit(1)
            token_address = args[1] if len(args) > 1 else None
            wallet = args[2] if len(args) > 2 else None
            cmd_balance(args[0], token_address, wallet)
        
        elif command == "execute":
            if len(args) < 3:
                print("Usage: execute <chain_id> <to_address> <calldata>")
                sys.exit(1)
            cmd_execute(args[0], args[1], args[2])
        
        elif command == "allowance":
            if len(args) < 3:
                print("Usage: allowance <chain_id> <token_address> <spender>")
                sys.exit(1)
            cmd_allowance(args[0], args[1], args[2])
        
        elif command == "approve":
            if len(args) < 3:
                print("Usage: approve <chain_id> <token_address> <spender> [amount]")
                sys.exit(1)
            amount = args[3] if len(args) > 3 else None
            cmd_approve(args[0], args[1], args[2], amount)
        
        elif command == "pay":
            if len(args) < 3:
                print("Usage: pay <link_id> <chain_id> <token> [email]")
                sys.exit(1)
            email = args[3] if len(args) > 3 else None
            cmd_pay(args[0], args[1], args[2], email)
        
        else:
            print(f"Unknown command: {command}")
            print(__doc__)
            sys.exit(1)
    
    except Exception as e:
        print(json.dumps({"error": str(e)}, indent=2, ensure_ascii=False))
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/signer.py
content: """
EIP-712 Signing utilities using eth-account
"""

from eth_account import Account
from eth_account.messages import encode_typed_data

from config import get_config
from eip712 import (
    get_eip712_domain,
    PAYMENT_SESSION_CREATION_TYPES,
    PREPARE_PAYMENT_TYPES,
    PaymentSessionCreationAuthorizationMessage,
    PreparePaymentAuthorizationMessage,
)

_account_instance = None


def get_account() -> Account:
    """
    Get or create account instance from private key in environment
    
    Returns:
        Account: eth-account Account instance
    """
    global _account_instance
    if _account_instance is None:
        config = get_config()
        _account_instance = Account.from_key(config.wallet_private_key)
    return _account_instance


def get_wallet_address() -> str:
    """
    Get the wallet address
    
    Returns:
        str: Wallet address
    """
    return get_account().address


def sign_create_session_message(
    auth_msg: PaymentSessionCreationAuthorizationMessage,
    chain_id: int | str
) -> str:
    """
    Sign a PaymentSessionCreationAuthorization message
    
    Args:
        auth_msg: Authorization message
        chain_id: Chain ID
        
    Returns:
        str: Signature
    """
    account = get_account()
    domain = get_eip712_domain(chain_id)
    
    # Build full typed data
    typed_data = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
            ],
            **PAYMENT_SESSION_CREATION_TYPES,
        },
        "primaryType": "PaymentSessionCreationAuthorization",
        "domain": domain,
        "message": auth_msg.to_signable_message(),
    }
    
    signable = encode_typed_data(full_message=typed_data)
    signed = account.sign_message(signable)
    
    return "0x" + signed.signature.hex()


def sign_prepare_payment_message(
    auth_msg: PreparePaymentAuthorizationMessage,
    chain_id: int | str
) -> str:
    """
    Sign a PreparePaymentAuthorization message
    
    Args:
        auth_msg: Authorization message
        chain_id: Chain ID
        
    Returns:
        str: Signature
    """
    account = get_account()
    domain = get_eip712_domain(chain_id)
    
    # Build full typed data
    typed_data = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
            ],
            **PREPARE_PAYMENT_TYPES,
        },
        "primaryType": "PreparePaymentAuthorization",
        "domain": domain,
        "message": auth_msg.to_signable_message(),
    }
    
    signable = encode_typed_data(full_message=typed_data)
    signed = account.sign_message(signable)
    
    return "0x" + signed.signature.hex()


path: scripts/api.py
content: """
GStable API Client
"""

import os
from typing import Any

import httpx


def _get_api_base_url() -> str:
    """Get API base URL from environment or use default"""
    return os.environ.get("GSTABLE_API_BASE_URL", "https://aipay.gstable.io/api/v1")


async def _api_request(endpoint: str, method: str = "GET", json_data: dict | None = None) -> dict[str, Any]:
    """
    Make an API request to GStable
    
    Args:
        endpoint: API endpoint
        method: HTTP method
        json_data: JSON body data
        
    Returns:
        dict: API response data
        
    Raises:
        Exception: If API returns error
    """
    url = f"{_get_api_base_url()}{endpoint}"
    
    async with httpx.AsyncClient() as client:
        if method == "GET":
            response = await client.get(url, headers={"Content-Type": "application/json"})
        else:
            response = await client.post(url, json=json_data, headers={"Content-Type": "application/json"})
        
        data = response.json()
        
        if data.get("code") != 0:
            raise Exception(f"API Error: {data.get('message')} (code: {data.get('code')})")
        
        return data


def _api_request_sync(endpoint: str, method: str = "GET", json_data: dict | None = None) -> dict[str, Any]:
    """
    Make a synchronous API request to GStable
    
    Args:
        endpoint: API endpoint
        method: HTTP method
        json_data: JSON body data
        
    Returns:
        dict: API response data
        
    Raises:
        Exception: If API returns error
    """
    url = f"{_get_api_base_url()}{endpoint}"
    
    with httpx.Client() as client:
        if method == "GET":
            response = client.get(url, headers={"Content-Type": "application/json"})
        else:
            response = client.post(url, json=json_data, headers={"Content-Type": "application/json"})
        
        data = response.json()
        
        if data.get("code") != 0:
            raise Exception(f"API Error: {data.get('message')} (code: {data.get('code')})")
        
        return data


async def get_payment_link(link_id: str) -> dict[str, Any]:
    """
    Get payment link details
    GET /payment/link/:linkId
    
    Args:
        link_id: Payment link ID
        
    Returns:
        dict: Payment link data
    """
    response = await _api_request(f"/payment/link/{link_id}")
    return response.get("data", response)


def get_payment_link_sync(link_id: str) -> dict[str, Any]:
    """
    Get payment link details (sync)
    GET /payment/link/:linkId
    
    Args:
        link_id: Payment link ID
        
    Returns:
        dict: Payment link data
    """
    response = _api_request_sync(f"/payment/link/{link_id}")
    return response.get("data", response)


async def create_payment_session(message: dict[str, Any], signature: str) -> dict[str, Any]:
    """
    Create a payment session
    POST /payment/session/create
    
    Args:
        message: EIP-712 message
        signature: Signature
        
    Returns:
        dict: Session data
    """
    response = await _api_request(
        "/payment/session/create",
        method="POST",
        json_data={"message": message, "signature": signature}
    )
    return response.get("data", response)


def create_payment_session_sync(message: dict[str, Any], signature: str) -> dict[str, Any]:
    """
    Create a payment session (sync)
    POST /payment/session/create
    
    Args:
        message: EIP-712 message
        signature: Signature
        
    Returns:
        dict: Session data
    """
    response = _api_request_sync(
        "/payment/session/create",
        method="POST",
        json_data={"message": message, "signature": signature}
    )
    return response.get("data", response)


async def get_payment_session(session_id: str) -> dict[str, Any]:
    """
    Get payment session details
    GET /session/:sessionId
    
    Args:
        session_id: Session ID
        
    Returns:
        dict: Session data
    """
    response = await _api_request(f"/session/{session_id}")
    return response.get("data", response)


def get_payment_session_sync(session_id: str) -> dict[str, Any]:
    """
    Get payment session details (sync)
    GET /session/:sessionId
    
    Args:
        session_id: Session ID
        
    Returns:
        dict: Session data
    """
    response = _api_request_sync(f"/session/{session_id}")
    return response.get("data", response)


async def prepare_payment(message: dict[str, Any], signature: str) -> dict[str, Any]:
    """
    Prepare payment and get calldata
    POST /payment/prepare
    
    Args:
        message: EIP-712 message
        signature: Signature
        
    Returns:
        dict: Prepare payment data with calldata
    """
    response = await _api_request(
        "/payment/prepare",
        method="POST",
        json_data={"message": message, "signature": signature}
    )
    return response.get("data", response)


def prepare_payment_sync(message: dict[str, Any], signature: str) -> dict[str, Any]:
    """
    Prepare payment and get calldata (sync)
    POST /payment/prepare
    
    Args:
        message: EIP-712 message
        signature: Signature
        
    Returns:
        dict: Prepare payment data with calldata
    """
    response = _api_request_sync(
        "/payment/prepare",
        method="POST",
        json_data={"message": message, "signature": signature}
    )
    return response.get("data", response)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)