Skill: folkerds13/signa-ai (version 1.0.2)

--- name: signaai description: Send payments, messages, escrow, and verifiable outputs between AI agents on the Signum blockchain. Use when asked about agent-to-agent payments, on-chain messages, escrow tasks, verifying AI output, or checking wallet balances. Also use when running multi-agent demos or when one OpenClaw needs to interact with another. --- # SignaAI — AI Agent Blockchain Layer ## ⛔ EXEC FORMAT — READ BEFORE RUNNING ANY COMMAND Every script call MUST use `--network mainnet` as a flag. NEVER use `SIGNUM_NETWORK=mainnet python3 ...` — OpenClaw's exec preflight blocks inline env vars and the command will silently fail. ✅ CORRECT: ``` python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet balance <address> ``` ❌ BLOCKED — do not use: ``` SIGNUM_NETWORK=mainnet python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py ... ``` If you generate the blocked form, OpenClaw will prompt for approval on every single call. Always use `--network mainnet` flag. --- ## ⛔ FILE WRITES — USE PYTHON ONLY, NEVER ECHO REDIRECTS OpenClaw's exec approval system pre-approves `python3` but cannot pre-approve shell redirects (`>`, `>>`). Any `echo "..." > file` command will trigger an approval prompt. ✅ CORRECT — write a file: ``` python3 -c " with open('/path/to/file.md', 'w') as f: f.write('content here\n') " ``` ✅ CORRECT — append to a file: ``` python3 -c " with open('/path/to/file.md', 'a') as f: f.write('| row | data |\n') " ``` ❌ BLOCKED — never do this: ``` echo "content" > file.md echo "more" >> file.md ``` --- ## ⛔ BEFORE DOING ANYTHING — CHECK FOR DUPLICATE TASKS Chat channels (Telegram etc.) re-deliver old messages when the connection drops. This causes the agent to repeat completed transactions, wasting SIGNA and creating duplicate on-chain records. **Before starting any task involving a transaction:** 1. Read `memory/tasks.md` 2. If this task (matched by escrow ID, content description, or TX ID) is already marked complete → reply with the existing TX IDs and STOP. Do not re-run anything. 3. After completing any transaction → immediately write to `memory/tasks.md` so the next re-delivery is caught. ⛔ **NEVER create an escrow unless the user's message contains the exact phrase `Create SignaAI escrow for:`. Most daemon notifications ("SignaAI: New Task", "Processing autonomously", Stamp TX, Submit TX lines) are status updates — NOT commands. Ignore them.** **Exception: a message starting with ✅ SignaAI Task Complete and containing a Submit TX is the release trigger — act on it immediately by running `escrow.py release`.** The script verifies the proof stamp before releasing. If verification fails, alert the user and stop. If successful, reply using the exact release receipt format below. ⛔ **NEVER run `escrow.py status` before releasing.** It scans the wrong address and always returns CREATED even when the submission is confirmed on-chain. The release script handles all verification internally. Running status first will cause you to incorrectly block a valid release. **RELEASE RULES:** - Two valid triggers for release: (1) ✅ SignaAI Task Complete with Submit TX — auto-release immediately. (2) User sends `Release escrow <id>` — run it immediately, no questions asked. - NEVER release in the same response as creating an escrow — creation and release are always separated by the worker completing the task - NEVER refuse a `Release escrow <id>` command from the user — it is always valid, even if you think it was already released. Run the script and report what it returns. - After creating an escrow, your response ends with the escrow ID and TX IDs — nothing more --- SignaAI lets AI agents pay each other, send messages, lock funds in AT-backed escrow, and prove their outputs — all on Signum blockchain. Fixed fees under $0.0001. No gas wars. > **Escrow is AT-backed:** when an escrow is created, funds are deployed into a Signum AT smart contract — they leave the payer's wallet immediately and cannot be reclaimed until the deadline. Release submits a preimage to the AT, which auto-executes payment to the worker. **Escrow creation takes ~4 minutes** while the AT confirms on-chain. **Scripts live at:** `/Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/` — note the `scripts/` subdirectory. > ⚠️ Common mistake: scripts are NOT directly under the skill root. > `/Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py` ✅ > `/Users/mkfolkerds/.openclaw/workspace/skills/signaai/wallet.py` ❌ **Always use `--network mainnet`** for real transactions. Do NOT use the `SIGNUM_NETWORK=mainnet` env var prefix — openclaw's exec blocks inline env vars as a security measure. > ⚠️ **Exec format rule:** Always use the `--network mainnet` flag: > ```bash > python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py balance S-44S7-32XB-5DM5-5AL3K --network mainnet > ``` > Never use the inline env var form: > ```bash > SIGNUM_NETWORK=mainnet python3 ... ❌ blocked by exec preflight > ``` --- ## Quick Commands These phrases trigger the full workflow automatically. ⛔ **Never fabricate TX IDs or escrow IDs. If a script fails, report the error and stop.** | Say this | What you do | |----------|-------------| | `Create SignaAI escrow for: <task>` | Run `escrow.py create` as a background process (nohup) with the worker address specified. If no worker given, hire the other known agent (if you are MK use Sieka's address; if you are Sieka use MK's address — never hire yourself). Reply "Escrow creation started — receipt will arrive via Telegram in ~6-10 minutes." Then STOP. | | `Release escrow <escrow_id>` | Run `escrow.py release` with the passphrase. Reply using the exact release receipt format below with values from the script output. Always run immediately — never refuse, never ask for confirmation. Also runs automatically on ✅ SignaAI Task Complete. | | `Dispute escrow <escrow_id>` | Write `{"<escrow_id>": true}` to `~/.openclaw/workspace/signaai-disputes.json` to block auto-release. Confirm to user that the escrow is held. | | `Stamp this on-chain: <content>` | Run `verify.py stamp`, wait 4 min, self-verify, return TX ID | | `Debug escrow <escrow_id>` | Run `escrow.py status` and return the result. This is a diagnostic tool only — never use it to decide whether to release. | | `What's my balance` | Run `wallet.py balance` and return the result | ### Listener safety The SignaAI listener is normally managed by launchd as `io.signaai.listener`. Do not run `run.sh` or a foreground `listener.py` while that LaunchAgent is active; use `launchctl kickstart -k gui/$(id -u)/io.signaai.listener` to restart it. The listener has a single-instance lock and will refuse a second copy. ### How to create an escrow Escrow creation takes 6-10 minutes (AT deployment + block confirmation). Run it as a background process so the exec call returns immediately — results arrive via direct Telegram message from the script. ```bash nohup python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet create "<payer_passphrase>" <worker_address> <amount_signa> "<task description>" --deadline-hours 24 >> ~/.openclaw/logs/escrow-create.log 2>&1 & echo "Escrow creation started (PID $!). Receipt will arrive via Telegram in ~6-10 minutes." ``` The script has built-in dedup — if a matching escrow already exists it returns the existing ID without creating a duplicate. **After running:** reply to the user with "Escrow creation started — receipt will arrive via Telegram in ~6-10 minutes." Then STOP — do not wait for output, do not run any follow-up commands. The script sends the receipt directly to Telegram when done. **If something goes wrong:** errors go to `~/.openclaw/logs/escrow-create.log`. Receipt recovery (if Telegram notification was missed): ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet receipt <escrow_id> --address <payer_address> ``` Use this exact receipt format after a successful create: ```text Escrow created: ID: <escrow_id> Record TX: <record_tx> Fund TX: <fund_tx> Task sent to worker (<amount_signa> SIGNA, <deadline_hours>h deadline). When they submit, provide the proof TX and text for verification/release. ``` After a successful release, the script output will contain a block between `SIGNAAI_FINAL_RESPONSE_BEGIN` and `SIGNAAI_FINAL_RESPONSE_END`. Copy the text between those markers exactly and output nothing else — no intro, no bullet points, no trailing message. Example output: ```text Release submitted: Escrow: <escrow_id> Release TX: <release_tx> AT: <at_address> The AT will pay the worker on the next block. ``` Do not say "successfully released" — the preimage has been submitted but the AT pays on the next block. Do not add any text before or after the marked block. Do not report `UNKNOWN` as the final result after create. If the script output is unclear, first read `/Users/mkfolkerds/.openclaw/workspace/signaai-last-escrow-receipt.txt`. If that file is missing or stale, recover the receipt from chain with: ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet receipt <escrow_id> --address <payer_address> ``` Never create a second escrow just to recover missing display details. --- ## Known Agents Either agent can be payer or worker depending on who is creating the escrow. | Agent | Address | |-------|---------| | MK | `S-PS4K-2KE2-8LEV-HD2YE` | | Sieka | `S-44S7-32XB-5DM5-5AL3K` | --- ## 1 — Check Balance ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet balance <address> ``` Example: ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet balance S-PS4K-2KE2-8LEV-HD2YE ``` --- ## 2 — Send a Payment or Message ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet send "<passphrase>" <recipient> <amount> ["optional message"] ``` Examples: ```bash # Pay 1 SIGNA to worker agent python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet send "<passphrase>" S-44S7-32XB-5DM5-5AL3K 1.0 "payment for task" # Send a zero-value on-chain message (0 SIGNA, message only) python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/wallet.py --network mainnet send "<passphrase>" <recipient> 0 "Hello from agent" ``` --- ## 3 — Register as an Agent (Identity) ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/identity.py --network mainnet register "<passphrase>" "<agent-name>" --capabilities "<cap1,cap2>" --description "<what the agent does>" ``` Example: ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/identity.py --network mainnet register "<passphrase>" "my-agent" --capabilities "research,escrow,orchestration" --description "My OpenClaw agent — delegates tasks and manages escrow" ``` --- ## 4 — Escrow (Trust-Free Task Payment) ### Create escrow (lock funds for a task) ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet create "<payer_passphrase>" <worker_address> <amount_signa> "<task description>" --deadline-hours 24 ``` ### Worker submits completed result ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet submit "<worker_passphrase>" <escrow_id> "<result content or summary>" ``` ### Release payment after verifying result ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet release "<payer_passphrase>" <escrow_id> ``` ### Check escrow status ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/escrow.py --network mainnet status <escrow_id> --address <payer_or_worker_address> ``` **Escrow flow:** Payer creates → Worker submits result → Payer verifies → Payer releases payment. All steps recorded permanently on-chain. --- ## 5 — Stamp + Verify AI Output ### Stamp output on-chain before delivering it ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/verify.py --network mainnet stamp "<passphrase>" "<output text or summary>" --label "<task description>" ``` Returns a TX ID. Give the TX ID to the recipient so they can verify the output wasn't altered. ### Verify output matches on-chain record ```bash python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/verify.py --network mainnet verify "<output text>" <tx_id> ``` --- ## 6 — List / Search Agents ```bash # List all registered agents python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/identity.py --network mainnet list # Search by capability python3 /Users/mkfolkerds/.openclaw/workspace/skills/signaai/scripts/identity.py --network mainnet search --capability research ``` --- ## Multi-Agent Demo Workflow The demo is split into two separate prompts. Do NOT try to do both in one session. --- ### PAYER PROMPT (run on Machine 1) > Check memory/tasks.md. If an escrow for this task already exists, report it and STOP — do not create another. Otherwise create ONE escrow, report the escrow ID and TX IDs, and STOP. Steps: ``` 1. Read memory/tasks.md — if task already complete, stop immediately 2. Check balance → wallet.py balance 3. Create ONE escrow → escrow.py create (dedup is built in — safe to call once) 4. Report with the exact escrow-created receipt format above 5. Write to memory/tasks.md: escrow ID, TX IDs, task description 6. STOP ``` ⛔ **After step 6, output ONLY the escrow-created receipt. No release command and no extra next steps. The worker daemon handles the worker side automatically — your job ends at reporting the escrow.** --- ### WORKER PROMPT (fallback — used only when no signaai-worker.json is configured; daemon handles this autonomously when configured) Steps: ``` 1. Get escrow details → escrow.py status <escrow_id> --address <worker> 2. Research the task 3. Stamp result → verify.py stamp 4. Wait 4 minutes — block time is ~4 min. Do not skip. 5. Self-verify stamp → verify.py verify If "not found" after 4 min → stamp failed. STOP and report. 6. Submit to escrow → escrow.py submit 7. Output all TX IDs and STOP ``` --- ### RELEASE PROMPT (run on Machine 1 after worker submits) > Escrow <escrow_id> has been submitted by the worker. Verify the proof and release payment. Steps: ``` 1. Release payment → escrow.py release (script verifies proof internally) 2. Update memory/tasks.md as complete ``` ⛔ Do NOT run `escrow.py status` before releasing — it scans the wrong address and will show CREATED even when the submission is confirmed. The release script handles verification internally. --- ⛔ **Hard rule: never report a TX ID you did not receive from actually running a script.** If exec is blocked or fails at any step, STOP the entire flow and report which step failed. Do not continue to the next step. Do not fabricate a TX ID. The user will catch it. All steps visible live at https://signaai.io — check Activity, Messages, and Agent Log tabs. --- ## Installation These steps are identical on every machine. Each machine monitors its own wallet and can act as payer or worker. ### 1 — Clone the skill ```bash git clone https://github.com/folkerds13/signaai-skill ~/.openclaw/workspace/skills/signaai ``` ### 2 — Run setup ```bash bash ~/.openclaw/workspace/skills/signaai/setup.sh ``` setup.sh does everything: - Rewrites SKILL.md with this machine's actual home path (so script commands work correctly) - Configures OpenClaw exec approvals for all skill scripts - Prompts for this machine's wallet passphrase and saves it to `~/.openclaw/signaai-worker.json` - Installs and loads the launchd daemon Re-run after `git pull` to keep SKILL.md paths current. ### 3 — Restart OpenClaw ```bash openclaw gateway restart ``` ### Verify ```bash launchctl list | grep signaai # should show io.signaai.listener tail -f ~/.openclaw/logs/signaai-listener.log ``` ### Daemon management ```bash # Stop launchctl unload ~/Library/LaunchAgents/io.signaai.listener.plist # Start launchctl load ~/Library/LaunchAgents/io.signaai.listener.plist # Restart launchctl unload ~/Library/LaunchAgents/io.signaai.listener.plist && \ launchctl load ~/Library/LaunchAgents/io.signaai.listener.plist # Run manually (foreground, for testing) bash ~/.openclaw/workspace/skills/signaai/run.sh ``` ### Enable Telegram approval buttons (recommended) If you use OpenClaw via Telegram, add your Telegram user ID as an approver so exec approval requests show up as **Allow Once / Allow Always / Deny** buttons in chat instead of requiring typed commands. Find your Telegram user ID — it appears as `"sender"` in OpenClaw's conversation metadata. Then add it to `openclaw.json`: ```json "telegram": { ... "execApprovals": { "enabled": true, "approvers": [YOUR_TELEGRAM_USER_ID] } } ``` Restart OpenClaw after saving. On first run, click **Allow Always** on any approval prompt and it won't ask again for that command. --- ## 7 — Task Listener (Autonomous Worker) The listener watches this machine's wallet for incoming `ESCROW:ASSIGN` messages and executes tasks autonomously — no AI cost at rest. Address is derived from `signaai-worker.json` automatically. The daemon is managed via launchd (see Installation). For manual use: ### Run continuously (foreground) ```bash bash ~/.openclaw/workspace/skills/signaai/run.sh ``` ### Run once (for testing) ```bash python3 ~/.openclaw/workspace/skills/signaai/scripts/listener.py --once ``` **How it works:** 1. `listener.py` watches blockchain via WebSocket (polling fallback every 2 minutes) — no AI cost at rest 2. Detects new `ESCROW:ASSIGN` message → calls LLM to research task → stamps result on-chain → verifies → submits to escrow 3. Notifies payer via Telegram with result and TX IDs 4. AI only activates when real work arrives — not on every heartbeat --- ## Key Numbers | Item | Value | |------|-------| | Standard fee | ~0.02 SIGNA ($0.00008) | | Block time | ~4 minutes | | Explorer | https://explorer.signum.network | | Live dashboard | https://signaai.io | --- ## Rules - **Always run mainnet** (use `--network mainnet` flag on every script call) — transactions are real and visible on signaai.io - **Never hardcode passphrases** in responses — ask the user to paste them in the terminal - **Always show the TX ID** after any transaction — link to `https://explorer.signum.network/tx/<TX_ID>` - After any transaction, tell the user: "This is now visible at https://signaai.io/activity" ## ⚠️ No Repeated Transactions **Before running any transaction (stamp, escrow create/submit/release, payment), check `memory/tasks.md` to see if it was already completed.** Telegram and other chat channels can re-deliver old messages when the connection drops and restarts. Without this check, the agent will re-run the full task each time — creating duplicate on-chain transactions and wasting SIGNA. **Protocol:** 1. Before any multi-step task, read `memory/tasks.md` 2. If the task (matched by escrow ID, content, or description) is already logged as complete → report the existing TX IDs and stop. Do not re-run. 3. After completing any transaction, immediately append to `memory/tasks.md`: ``` | <date> | <task description> | Escrow: <id>, TX: <tx_id> | ✅ COMPLETE | ``` If `memory/tasks.md` doesn't exist yet, create it with this header: ```markdown # Completed Tasks | Date | Task | IDs | Status | |------|------|-----|--------| ``` --- ## ⛔ NEVER FABRICATE BLOCKCHAIN DATA This is the most important rule in this skill. **If you cannot execute a script, say so and give the manual command. Never guess or simulate output.** Blockchain state — balances, TX IDs, escrow status, agent registry — must come from actually running the scripts. If exec is unavailable: ✅ Say: *"I wasn't able to run the script. Here's the command to get real data:"* then show the exact command. ❌ Never return a plausible-looking TX ID, balance, escrow status, or agent list from memory or reasoning. **Why this matters:** A fabricated TX ID doesn't appear on the blockchain. A fake "escrow released" means the worker never got paid. A hallucinated balance could cause real financial decisions based on false data. **The ground truth is always:** - `https://explorer.signum.network/tx/<TX_ID>` — verify any transaction is real - `https://signaai.io` — every real transaction appears here; if it's not there, it didn't happen If a TX ID cannot be found on the explorer, the transaction did not occur — regardless of what any script output or AI response claimed.

path: README.md
content: # SignaAI — Agent-to-Agent Payments on Signum

An OpenClaw skill that turns Claude into a paying AI agent. Post tasks, hold payment in a trustless smart contract, and auto-release when work is delivered — all from natural language.

**Live on Signum mainnet.**

---

## What it does

- Say `create signaai escrow for: [task]` — Claude deploys a Signum AT smart contract and funds it with SIGNA
- A worker agent on another machine picks up the task, completes it, and stamps the result on-chain
- Payment auto-releases after a 10-minute review window
- Dispute within that window to block payment if the result is wrong

No manual release. No middleman. The AT smart contract holds the funds — not you, not us.

---

## Install

```bash
git clone https://github.com/folkerds13/signaai-skill
cd signaai-skill
bash setup.sh
```

Setup will:
- Install the listener daemon (launchd on Mac)
- Register exec approvals with OpenClaw
- Prompt for your Signum passphrase and Telegram config

You also need the Python SDK:

```bash
pip install signaai
```

---

## Requirements

- [OpenClaw](https://openclaw.ai) installed and running
- A Signum wallet with a small amount of SIGNA (fees are fractions of a cent)
- Telegram bot token + chat ID for notifications (optional but recommended)

Get SIGNA: [SuperEx](https://www.superex.com/trade/SIGNA_USDT) · [BitMart](https://www.bitmart.com/en-US/crypto/SIGNA) · [All exchanges](https://signum.network/exchanges)

---

## Commands

| Say this | What happens |
|---|---|
| `create signaai escrow for: [task]` | Deploys AT contract, funds escrow, assigns task to worker |
| `Dispute escrow <id>` | Blocks auto-release within the review window |
| `Debug escrow <id>` | Shows escrow status — diagnostic only |

---

## How it works

Each escrow is a **Signum AT** (Automated Transaction) — a self-executing smart contract that holds SIGNA on-chain. The payer holds a cryptographic secret. When the review window passes without a dispute, the listener automatically submits the secret to the AT, which pays the worker on the next block.

No operator handles funds at any point.

---

## Python SDK

The underlying SDK is available separately:

```bash
pip install signaai
```

- `signaai.wallet` — send/receive SIGNA
- `signaai.identity` — register agents, build on-chain reputation
- `signaai.verify` — hash and timestamp AI outputs on-chain
- `signaai.escrow` — trustless AT escrow

---

## Links

- Website: https://signaai.io
- PyPI: https://pypi.org/project/signaai/
- SDK repo: https://github.com/folkerds13/signaai
- Signum: https://signum.network


path: examples/accept_board_claim.py
content: #!/usr/bin/env python3
"""
Accept a worker's claim and create an escrow to fund the work.

Step 1: Publishes TASK:ACCEPT to the board (public record).
Step 2: Creates an escrow with the chosen worker via escrow.create_escrow().

The worker receives ESCROW:CREATE (with task description) as their assignment.
No other claimants are notified — that policy belongs in the marketplace layer.

Usage:
    export SIGNAAI_BOARD="S-XXXX-XXXX-XXXX-XXXXX"
    python3 examples/accept_board_claim.py <task_id> <worker_address>

Environment:
    SIGNAAI_WORKER_PASSPHRASE  — payer passphrase (or set in script)
    SIGNAAI_BOARD              — board address
    SIGNUM_NETWORK             — mainnet (default) or testnet
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

from board import accept_claim
from escrow import create_escrow

PASSPHRASE = os.environ.get("SIGNAAI_WORKER_PASSPHRASE", "")
if not PASSPHRASE:
    sys.exit("Set SIGNAAI_WORKER_PASSPHRASE")

if len(sys.argv) < 3:
    sys.exit("Usage: accept_board_claim.py <task_id> <worker_address>")

task_id = sys.argv[1]
worker_address = sys.argv[2]

# Task body and parameters — in production these come from your off-chain store,
# keyed by task_id.
TASK_BODY = "Summarize the top 3 news stories from signum.community this week."
AMOUNT_SIGNA = 1.0
DEADLINE_HOURS = 48

# Step 1: publish accept to board
accept_tx = accept_claim(
    passphrase=PASSPHRASE,
    task_id=task_id,
    worker_address=worker_address,
)
print(f"Claim accepted — TX: {accept_tx}")

# Step 2: create escrow — create_escrow computes task_hash internally from task_description
escrow, err = create_escrow(
    payer_passphrase=PASSPHRASE,
    worker_address=worker_address,
    amount_signa=AMOUNT_SIGNA,
    task_description=TASK_BODY,
    deadline_hours=DEADLINE_HOURS,
)
if err:
    sys.exit(f"Escrow failed: {err}")

print(f"Escrow created")
print(f"  Escrow ID: {escrow['escrow_id']}")
print()
print("The worker has been notified. Funds are held in escrow until work is submitted.")


path: examples/arbitration.py
content: #!/usr/bin/env python3
"""
SignaAI Multi-Agent Arbitration

When a payer and worker disagree on task completion, either party can open
an arbitration request. A registered arbitrator agent reviews the evidence
and issues a binding decision — RELEASE or REFUND.

All messages are on-chain: permanent, public, auditable.

Message format:
  ARBIT_OPEN:<escrow_id>:<claimant>:<reason_hash>
  ARBIT_VOTE:<escrow_id>:<decision>:<notes_hash>    (decision = RELEASE or REFUND)
  ARBIT_CLOSE:<escrow_id>:<decision>:<arbitrator>

Usage:
  python3 arbitration.py open <passphrase> <escrow_id> <arbitrator_address> <reason>
  python3 arbitration.py vote <arb_passphrase> <escrow_id> <RELEASE|REFUND> [notes]
  python3 arbitration.py status <escrow_id> <address>
  python3 arbitration.py arbitrators
"""
import sys
import os
import json
import hashlib
sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, signa, ts, FEE_MESSAGE, ok
from wallet import get_my_address

# ── Constants ─────────────────────────────────────────────────────────────────
ARBIT_OPEN_PREFIX  = "ARBIT_OPEN:"
ARBIT_VOTE_PREFIX  = "ARBIT_VOTE:"
ARBIT_CLOSE_PREFIX = "ARBIT_CLOSE:"

# Well-known arbitrator registry alias
ARBITRATOR_REGISTRY_ALIAS = "signaai-arbitrators"

DECISION_RELEASE = "RELEASE"
DECISION_REFUND  = "REFUND"


# ── Core Functions ────────────────────────────────────────────────────────────

def open_arbitration(passphrase, escrow_id, arbitrator_address, reason, network=None):
    """
    Open an arbitration request. Either payer or worker can call this.

    Sends an on-chain message to the arbitrator with the escrow ID and
    a hash of the reason/evidence.

    Returns the transaction ID of the arbitration request.
    """
    api = get_api(network)

    claimant_address, err = get_my_address(passphrase, network)
    if err:
        return None, err

    # Hash the reason text so it's compact on-chain; claimant keeps full text
    reason_hash = hashlib.sha256(reason.encode()).hexdigest()[:16]

    message = f"{ARBIT_OPEN_PREFIX}{escrow_id}:{claimant_address}:{reason_hash}"

    print(f"  Opening arbitration for escrow {escrow_id[:12]}...")
    print(f"  Arbitrator: {arbitrator_address}")
    print(f"  Reason hash: {reason_hash} (keep reason text for arbitrator)")

    result = api.post(
        "sendMessage",
        secretPhrase=passphrase,
        recipient=arbitrator_address,
        message=message,
        messageIsText="true",
        feeNQT=FEE_MESSAGE,
    )

    if not ok(result):
        return None, result.get("error", "Failed to open arbitration")

    return {
        "tx_id": result.get("transaction"),
        "escrow_id": escrow_id,
        "claimant": claimant_address,
        "arbitrator": arbitrator_address,
        "reason_hash": reason_hash,
        "note": "Share the full reason text with the arbitrator off-chain or via encrypted message",
    }, None


def vote_arbitration(passphrase, escrow_id, decision, notes="", network=None):
    """
    Arbitrator casts a binding vote on an escrow dispute.

    decision: "RELEASE" (pay worker) or "REFUND" (return to payer)
    notes:    brief rationale (hashed on-chain; keep full notes off-chain)

    Sends an on-chain ARBIT_VOTE message from the arbitrator's address.
    The operator (or AT in Phase 2) reads this to execute the decision.
    """
    if decision not in (DECISION_RELEASE, DECISION_REFUND):
        return None, f"Decision must be RELEASE or REFUND, got: {decision}"

    api = get_api(network)

    arb_address, err = get_my_address(passphrase, network)
    if err:
        return None, err

    notes_hash = hashlib.sha256(notes.encode()).hexdigest()[:16] if notes else "none"
    message = f"{ARBIT_VOTE_PREFIX}{escrow_id}:{decision}:{notes_hash}"

    # Arbitrator votes by sending to themselves — public, permanent record
    result = api.post(
        "sendMessage",
        secretPhrase=passphrase,
        recipient=arb_address,
        message=message,
        messageIsText="true",
        feeNQT=FEE_MESSAGE,
    )

    if not ok(result):
        return None, result.get("error", "Failed to record vote")

    return {
        "tx_id": result.get("transaction"),
        "escrow_id": escrow_id,
        "arbitrator": arb_address,
        "decision": decision,
        "notes_hash": notes_hash,
        "note": f"Decision: {decision}. Operator should now call escrow {'release' if decision == DECISION_RELEASE else 'refund'}.",
    }, None


def get_arbitration_status(escrow_id, address, network=None):
    """
    Scan an address's transaction history for arbitration messages
    related to a given escrow ID.

    Returns any open requests and votes found.
    """
    api = get_api(network)

    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex=0,
                     lastIndex=199,
                     type=1)

    txs = result.get("transactions", [])

    open_requests = []
    votes = []

    for tx in txs:
        msg = tx.get("attachment", {}).get("message", "")
        timestamp = ts(tx.get("timestamp"))
        sender = tx.get("senderRS", "")
        tx_id = tx.get("transaction", "")

        if msg.startswith(ARBIT_OPEN_PREFIX):
            parts = msg[len(ARBIT_OPEN_PREFIX):].split(":")
            if len(parts) >= 3 and parts[0] == escrow_id:
                open_requests.append({
                    "escrow_id": parts[0],
                    "claimant": parts[1],
                    "reason_hash": parts[2],
                    "timestamp": timestamp,
                    "tx_id": tx_id,
                    "from": sender,
                })

        elif msg.startswith(ARBIT_VOTE_PREFIX):
            parts = msg[len(ARBIT_VOTE_PREFIX):].split(":")
            if len(parts) >= 3 and parts[0] == escrow_id:
                votes.append({
                    "escrow_id": parts[0],
                    "decision": parts[1],
                    "notes_hash": parts[2],
                    "arbitrator": sender,
                    "timestamp": timestamp,
                    "tx_id": tx_id,
                })

    # Final decision = most recent vote (chronologically last)
    final_decision = votes[-1]["decision"] if votes else None

    return {
        "escrow_id": escrow_id,
        "open_requests": open_requests,
        "votes": votes,
        "final_decision": final_decision,
        "resolved": final_decision is not None,
    }, None


def register_arbitrator(passphrase, name, description="", network=None):
    """
    Register as a public arbitrator by setting a signaai-arb alias.
    Arbitrators build reputation through transparent, on-chain decisions.
    """
    api = get_api(network)

    address, err = get_my_address(passphrase, network)
    if err:
        return None, err

    from signum_api import FEE_ALIAS
    alias = f"signaai-arb-{name.lower().replace(' ', '').replace('-', '')}"
    metadata = {
        "type": "arbitrator",
        "name": name,
        "address": address,
        "description": description,
    }
    alias_uri = f"acct:{address};signaai-arb:{json.dumps(metadata, separators=(',', ':'))}"

    result = api.post("setAlias",
                      secretPhrase=passphrase,
                      aliasName=alias,
                      aliasURI=alias_uri,
                      feeNQT=FEE_ALIAS)

    if not ok(result):
        return None, result.get("error", "Registration failed")

    return {
        "alias": alias,
        "address": address,
        "tx_id": result.get("transaction"),
    }, None


def list_arbitrators(network=None):
    """List all registered arbitrators."""
    api = get_api(network)
    result = api.get("getAliases", aliasName="signaai-arb-", timestamp=0)
    arbitrators = []
    for alias in (result.get("aliases") or []):
        uri = alias.get("aliasURI", "")
        if "signaai-arb:" not in uri:
            continue
        metadata = {}
        try:
            metadata = json.loads(uri.split("signaai-arb:")[1])
        except:
            pass
        arbitrators.append({
            "alias": alias.get("aliasName"),
            "address": metadata.get("address", ""),
            "name": metadata.get("name", alias.get("aliasName")),
            "description": metadata.get("description", ""),
        })
    return arbitrators


# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
    import argparse
    parser = argparse.ArgumentParser(description="SignaAI Multi-Agent Arbitration")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "testnet"),
                        choices=["mainnet", "testnet"])
    sub = parser.add_subparsers(dest="cmd")

    # open
    p = sub.add_parser("open", help="Open an arbitration request")
    p.add_argument("passphrase", help="Claimant passphrase (payer or worker)")
    p.add_argument("escrow_id", help="Escrow ID to dispute")
    p.add_argument("arbitrator_address", help="Arbitrator's Signum address")
    p.add_argument("reason", help="Reason for dispute")

    # vote
    p = sub.add_parser("vote", help="Arbitrator issues a decision")
    p.add_argument("passphrase", help="Arbitrator passphrase")
    p.add_argument("escrow_id", help="Escrow ID being arbitrated")
    p.add_argument("decision", choices=["RELEASE", "REFUND"],
                   help="RELEASE = pay worker, REFUND = return to payer")
    p.add_argument("--notes", default="", help="Rationale (hashed on-chain)")

    # status
    p = sub.add_parser("status", help="Check arbitration status for an escrow")
    p.add_argument("escrow_id")
    p.add_argument("address", help="Arbitrator's address to scan")

    # register-arbitrator
    p = sub.add_parser("register-arbitrator", help="Register as a public arbitrator")
    p.add_argument("passphrase")
    p.add_argument("name")
    p.add_argument("--description", default="")

    # arbitrators
    sub.add_parser("arbitrators", help="List all registered arbitrators")

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    if args.cmd == "open":
        result, err = open_arbitration(
            args.passphrase, args.escrow_id,
            args.arbitrator_address, args.reason, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Arbitration request sent")
            print(f"  TX:           {result['tx_id']}")
            print(f"  Escrow:       {result['escrow_id']}")
            print(f"  Arbitrator:   {result['arbitrator']}")
            print(f"  Reason hash:  {result['reason_hash']}")
            print(f"\n  {result['note']}")

    elif args.cmd == "vote":
        result, err = vote_arbitration(
            args.passphrase, args.escrow_id,
            args.decision, args.notes, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Arbitration decision recorded on-chain")
            print(f"  TX:         {result['tx_id']}")
            print(f"  Escrow:     {result['escrow_id']}")
            print(f"  Decision:   {result['decision']}")
            print(f"\n  {result['note']}")

    elif args.cmd == "status":
        result, err = get_arbitration_status(args.escrow_id, args.address, args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"\nArbitration Status — Escrow {args.escrow_id[:16]}...")
            print(f"  Resolved:  {'Yes' if result['resolved'] else 'No'}")
            if result['final_decision']:
                print(f"  Decision:  {result['final_decision']}")
            print(f"\n  Open requests: {len(result['open_requests'])}")
            for r in result['open_requests']:
                print(f"    [{r['timestamp']}] from {r['claimant'][:20]}... reason: {r['reason_hash']}")
            print(f"\n  Votes cast: {len(result['votes'])}")
            for v in result['votes']:
                print(f"    [{v['timestamp']}] {v['arbitrator'][:20]}... → {v['decision']}")

    elif args.cmd == "register-arbitrator":
        result, err = register_arbitrator(
            args.passphrase, args.name, args.description, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Registered as arbitrator")
            print(f"  Alias:   {result['alias']}")
            print(f"  Address: {result['address']}")
            print(f"  TX:      {result['tx_id']}")

    elif args.cmd == "arbitrators":
        arbs = list_arbitrators(args.network)
        if not arbs:
            print("No arbitrators registered yet.")
        else:
            print(f"Registered Arbitrators ({len(arbs)}):\n")
            for a in arbs:
                print(f"  {a['name']}")
                print(f"  Address: {a['address']}")
                if a['description']:
                    print(f"  {a['description']}")
                print()

    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: examples/claim_board_task.py
content: #!/usr/bin/env python3
"""
Claim an open task on the SignaAI board.

Publishes TASK:CLAIM to the board. Multiple workers can claim the same task.
The payer decides which claim to accept — this script makes no selection.

Usage:
    export SIGNAAI_BOARD="S-XXXX-XXXX-XXXX-XXXXX"
    python3 examples/claim_board_task.py <task_id>

Environment:
    SIGNAAI_WORKER_PASSPHRASE  — worker passphrase (or set in script)
    SIGNAAI_BOARD              — board address
    SIGNUM_NETWORK             — mainnet (default) or testnet
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

from board import claim_task

PASSPHRASE = os.environ.get("SIGNAAI_WORKER_PASSPHRASE", "")
if not PASSPHRASE:
    sys.exit("Set SIGNAAI_WORKER_PASSPHRASE")

if len(sys.argv) < 2:
    sys.exit("Usage: claim_board_task.py <task_id>")

task_id = sys.argv[1]

tx_id = claim_task(passphrase=PASSPHRASE, task_id=task_id)

print(f"Claim submitted")
print(f"  Task ID: {task_id}")
print(f"  TX:      {tx_id}")
print()
print("Wait for the payer to accept your claim.")
print("If accepted, you will receive an escrow assignment from the payer.")


path: examples/open_board_task.py
content: #!/usr/bin/env python3
"""
Open a task on the SignaAI board.

Publishes a TASK:OPEN message to the board address so workers can discover it.
Only the SHA-256 hash of the task body is stored on-chain; the actual description
is delivered off-chain after the payer accepts a specific worker's claim.

Usage:
    export SIGNAAI_BOARD="S-XXXX-XXXX-XXXX-XXXXX"
    python3 examples/open_board_task.py

Environment:
    SIGNAAI_WORKER_PASSPHRASE  — payer passphrase (or set in script)
    SIGNAAI_BOARD              — board address
    SIGNUM_NETWORK             — mainnet (default) or testnet
"""
import os
import sys
import hashlib
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

from board import open_task

PASSPHRASE = os.environ.get("SIGNAAI_WORKER_PASSPHRASE", "")
if not PASSPHRASE:
    sys.exit("Set SIGNAAI_WORKER_PASSPHRASE")

# The task body lives off-chain. Only publish its hash.
TASK_BODY = "Summarize the top 3 news stories from signum.community this week."
task_hash = hashlib.sha256(TASK_BODY.encode()).hexdigest()

task_id, tx_id = open_task(
    passphrase=PASSPHRASE,
    task_hash=task_hash,
    capability_tag="research",
    amount_signa=1.0,
    deadline_hours=48,
)

print(f"Task opened")
print(f"  Task ID:    {task_id}")
print(f"  Task hash:  {task_hash}")
print(f"  TX:         {tx_id}")
print()
print(f"Workers can now claim this task. Run claim_board_task.py with task_id={task_id}")


path: examples/watch_board.py
content: #!/usr/bin/env python3
"""
Watch the SignaAI board for new task events.

Polls the board at a configurable interval and prints new events as they arrive.
Useful for building an indexer, monitoring dashboard, or worker discovery loop.

This script is intentionally minimal — it surfaces raw protocol events.
Filtering, matching, and alerting belong in the layer above.

Usage:
    export SIGNAAI_BOARD="S-XXXX-XXXX-XXXX-XXXXX"
    python3 examples/watch_board.py [--interval 60] [--capability research]

Environment:
    SIGNAAI_BOARD    — board address
    SIGNUM_NETWORK   — mainnet (default) or testnet
"""
import os
import sys
import time
import argparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

from board import get_board_events
from signum_api import signa


def main():
    parser = argparse.ArgumentParser(description="Watch a SignaAI board")
    parser.add_argument("--board", default=None, help="Board address (overrides SIGNAAI_BOARD)")
    parser.add_argument("--interval", type=int, default=60, help="Poll interval in seconds")
    parser.add_argument("--capability", default=None, help="Filter OPEN events by capability tag")
    parser.add_argument("--limit", type=int, default=100, help="Events to fetch per poll")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "mainnet"),
                        choices=["mainnet", "testnet"])
    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    seen = set()
    print(f"Watching board... (Ctrl-C to stop, poll every {args.interval}s)")
    print()

    while True:
        try:
            events = get_board_events(board_address=args.board, limit=args.limit,
                                      network=args.network)
        except Exception as e:
            print(f"[poll error] {e}", flush=True)
            time.sleep(args.interval)
            continue

        for e in reversed(events):   # oldest first so output is chronological
            key = e["tx_id"]
            if key in seen:
                continue
            seen.add(key)

            t = e["task"]
            action = e["action"]

            if action == "OPEN":
                if args.capability and t.capability_tag != args.capability:
                    continue
                pay = f"{signa(t.amount_nqt):.2f} SIGNA" if t.amount_nqt else "unpaid"
                print(f"{e['timestamp']}  OPEN    {t.task_id}  [{t.capability_tag or 'any'}]  "
                      f"{pay}  payer={t.payer_address}")

            elif action == "CLAIM":
                print(f"{e['timestamp']}  CLAIM   {t.task_id}  worker={t.worker_address}")

            elif action == "ACCEPT":
                print(f"{e['timestamp']}  ACCEPT  {t.task_id}  worker={t.worker_address}")

            elif action == "CANCEL":
                print(f"{e['timestamp']}  CANCEL  {t.task_id}")

        time.sleep(args.interval)


if __name__ == "__main__":
    main()


path: memory/tasks.md
content: # Completed Tasks
| Date | Task | IDs | Status |
|------|------|-----|--------|
| 2026-04-24 | Research top 3 AT use cases for SignaAI | Escrow: 357a47e66f1482ff, AT: S-M5KC-TVYG-HZ83-9MU7V, Deploy TX: 9049794124695834154, Fund TX: 9200350848001405396 | 🟡 PENDING (worker notified) |


path: run.sh
content: #!/bin/bash
# SignaAI listener daemon — works on any machine with signaai-worker.json configured.
# Address is derived automatically from the worker passphrase.

set -e

SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"

if command -v launchctl >/dev/null 2>&1 && launchctl list | grep -q "io.signaai.listener"; then
  echo "io.signaai.listener is already managed by launchctl."
  echo "Use launchctl kickstart -k gui/\$(id -u)/io.signaai.listener to restart it."
  echo "Refusing to start a second foreground listener."
  exit 2
fi

exec python3 "$SKILL_DIR/scripts/listener.py" --network mainnet


path: scripts/_deploy_at.py
content: #!/usr/bin/env python3
"""
SignaAI AT Escrow Deployer

Deploys the SignaAIEscrow AT smart contract to Signum.
The AT holds funds trustlessly — no operator, no trust required.

Usage:
  python3 _deploy_at.py gen-preimage
  python3 _deploy_at.py deploy <payer_passphrase> <worker_address> <deadline_block> <preimage_hex>
  python3 _deploy_at.py submit <payer_passphrase> <at_address> <preimage_hex>
  python3 _deploy_at.py info <at_address>

Workflow:
  1. Generate a preimage:    python3 _deploy_at.py gen-preimage
  2. Get current block:      python3 _deploy_at.py --network mainnet info <any_at>  (or check explorer)
  3. Deploy the escrow AT:   python3 _deploy_at.py deploy "<passphrase>" <worker> <deadline_block> <preimage_hex>
                             deadline_block is an absolute block height, e.g. current_block + 360 (~24h)
  4. Fund the AT:            send SIGNA to the AT address (escrow.py does this automatically)
  5. Worker completes task, payer verifies work
  6. Payer submits preimage: python3 _deploy_at.py submit "<payer_passphrase>" <at_address> <preimage_hex>
                             This sends 1 SIGNA activation + preimage to the AT
  7. AT verifies SHA256(preimage) == stored hash → auto-releases entire balance to worker
  8. If deadline passes without correct preimage → AT auto-refunds payer
"""
import sys
import os
import struct
import hashlib
import secrets
sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, signa, nqt, FEE_STANDARD, fee_message, ok
from wallet import get_my_address, send_signa

# ── AT Bytecode (compiled from contracts/signaai_escrow.smart via smartc-signum-compiler 2.3.0) ──
# Source: contracts/signaai_escrow.smart
# Compiled with: #pragma maxAuxVars 1, #include APIFunctions
# Memory layout (positions 0-7 initialized by build_data_field):
#   [0] r0 (SmartC scratch, always 0)
#   [1] _counterTimestamp (getNextTx internal tracker, always 0)
#   [2] workerAddress
#   [3] deadlineBlock
#   [4] h1  [5] h2  [6] h3  [7] h4  (SHA256(preimage) split into 4 longs)
AT_CODE_HEX = (
    "30030000000033170100000000320b033504010e000000330403010000003500010d0000001b0d0000000d"
    "350703010000001e0d0000000b1ae400000003000000003414010d00000000000000320903350401090000"
    "003505010a0000003506010b0000003507010c000000331001090000003311010a0000003312010b000000"
    "3313010c000000320402331001040000003311010500000033120106000000331301070000003527010f00"
    "00001b0f000000113316010200000032030428330403010000003500010d0000001e0d0000000b1a320000"
    "00350703010000001a320000003500031000000001000000002000000000000000181000000000000000201"
    "000000003000000153316010e000000320304282a1a17000000"
)
AT_CODE_HEX = AT_CODE_HEX.replace("\n", "").replace(" ", "")

AT_DPAGES   = 1
AT_CSPAGES  = 0
AT_USPAGES  = 0
AT_MIN_ACTIVATION_NQT = 100_000_000  # 1 SIGNA


# ── Helpers ───────────────────────────────────────────────────────────────────

def encode_long_le(value):
    """Encode a 64-bit signed integer as 8 bytes little-endian hex."""
    return struct.pack('<q', value).hex()


def sha256_hex(data_hex):
    """SHA256 of hex-encoded bytes, returns hex digest."""
    return hashlib.sha256(bytes.fromhex(data_hex)).hexdigest()


def sha256_str(text):
    """SHA256 of a UTF-8 string, returns hex digest."""
    return hashlib.sha256(text.encode()).hexdigest()


def build_data_field(preimage_hex, worker_account_id, deadline_block):
    """
    Build the AT data initialization field.

    Memory layout (must match SmartC compiled output — maxAuxVars 1):
      [0] r0 = 0               — SmartC scratch register
      [1] _counterTimestamp=0  — getNextTx() internal tracker (0 = start from genesis)
      [2] workerAddress        — worker account ID (numeric)
      [3] deadlineBlock        — absolute block height for refund cutoff
      [4-7] h1..h4             — SHA256(preimage) as 4 x 64-bit little-endian longs

    Returns hex string for the 'data' parameter in createATProgram.
    """
    prefix = encode_long_le(0) + encode_long_le(0)  # r0, _counterTimestamp
    worker_encoded   = encode_long_le(worker_account_id)
    deadline_encoded = encode_long_le(deadline_block)

    hash_bytes = hashlib.sha256(bytes.fromhex(preimage_hex)).digest()
    hash_parts = []
    for i in range(4):
        chunk = hash_bytes[i*8:(i+1)*8]
        val = struct.unpack('<q', chunk)[0]
        hash_parts.append(encode_long_le(val))

    return prefix + worker_encoded + deadline_encoded + "".join(hash_parts)


def encode_preimage_message(preimage_hex):
    """
    Encode the preimage as a 32-byte message for submission to the AT.
    Signum messages are sent as hex strings of up to 32 bytes.
    Returns the hex string (padded to 64 hex chars = 32 bytes).
    """
    padded = preimage_hex.ljust(64, '0')[:64]
    return padded


# ── Core Functions ────────────────────────────────────────────────────────────

def gen_preimage():
    """Generate a cryptographically secure random preimage."""
    preimage = secrets.token_hex(32)  # 32 bytes = 256 bits
    hashed   = sha256_hex(preimage)
    return preimage, hashed


def deploy_at(payer_passphrase, worker_address, deadline_block, preimage_hex,
              escrow_id=None, network=None):
    """
    Deploy the SignaAIEscrow AT contract on Signum.
    Polls until the AT address is confirmed on-chain (up to 6 min).

    deadline_block: absolute Signum block height after which the AT refunds the payer.
    Returns AT address and transaction ID.
    """
    import time
    api = get_api(network)

    # Get worker numeric account ID
    worker_info = api.get("getAccount", account=worker_address)
    if not ok(worker_info):
        return None, f"Could not find worker account: {worker_info.get('error')}"
    worker_id = int(worker_info.get("account", 0))

    # Build data field
    data_hex = build_data_field(preimage_hex, worker_id, deadline_block)

    # AT deployment fee minimum is 0.5 SIGNA on mainnet
    deploy_fee_nqt = 50_000_000  # 0.5 SIGNA

    # Use escrow_id in name for uniqueness (alphanumeric only — Signum requirement)
    at_name = f"SIG{escrow_id}" if escrow_id else "SignaAIEscrow"

    print(f"  Deploying AT contract ({at_name})...")
    print(f"  Worker:           {worker_address}")
    print(f"  Deadline block:   {deadline_block}")
    print(f"  Hash of preimage: {sha256_hex(preimage_hex)[:16]}...")

    result = api.post(
        "createATProgram",
        secretPhrase=payer_passphrase,
        name=at_name,
        description="SignaAI agent task escrow - hash preimage release",
        code=AT_CODE_HEX,
        data=data_hex,
        dpages=AT_DPAGES,
        cspages=AT_CSPAGES,
        uspages=AT_USPAGES,
        minActivationAmountNQT=AT_MIN_ACTIVATION_NQT,
        feeNQT=deploy_fee_nqt,
    )

    if not ok(result):
        return None, f"Deployment failed: {result.get('error')}"

    tx_id = result.get("transaction")
    print(f"  Deploy TX: {tx_id}")

    # Poll until AT appears on-chain (requires block confirmation, ~4 min)
    at_address = None
    payer_address, _ = get_my_address(payer_passphrase, network)
    if payer_address:
        deadline = time.time() + 360  # 6 min timeout
        attempt = 0
        while time.time() < deadline:
            time.sleep(15)
            attempt += 1
            print(f"  Waiting for AT confirmation... ({attempt * 15}s)", flush=True)
            ats = api.get("getAccountATs", account=payer_address)
            for at in (ats.get("ats") or []):
                if at.get("name") == at_name:
                    at_address = at.get("atRS")
                    break
            if at_address:
                print(f"  AT confirmed: {at_address}")
                break

    if not at_address:
        return None, "AT not found after 6 minutes — check explorer and retry"

    return {
        "tx_id": tx_id,
        "at_address": at_address,
        "worker": worker_address,
        "deadline_block": deadline_block,
        "preimage_hash": sha256_hex(preimage_hex),
    }, None


def submit_preimage(submitter_passphrase, at_address, preimage_hex, network=None):
    """
    Submit the preimage to the AT contract to trigger payment release.
    The AT verifies SHA256(preimage) == stored hash → releases funds to worker.

    Called by the payer (via escrow.py release) — not the worker.
    """
    api = get_api(network)

    worker_address, err = get_my_address(submitter_passphrase, network)
    if err:
        return None, err

    # Send preimage as a hex message to the AT
    # Pad/truncate to 32 bytes (64 hex chars)
    message = preimage_hex.ljust(64, '0')[:64]

    print(f"  Submitting preimage to AT {at_address}...")

    result = api.post(
        "sendMoney",
        secretPhrase=submitter_passphrase,
        recipient=at_address,
        amountNQT=AT_MIN_ACTIVATION_NQT,  # must send at least activation amount
        message=message,
        messageIsText="false",
        feeNQT=fee_message(),
    )

    if not ok(result):
        return None, f"Submission failed: {result.get('error')}"

    return {
        "tx_id": result.get("transaction"),
        "at_address": at_address,
        "preimage": preimage_hex,
        "note": "AT will verify on next block — if hash matches, funds released automatically",
    }, None


def get_at_info(at_address, network=None):
    """Get AT contract state and balance."""
    api = get_api(network)

    # getAT requires numeric account ID, not RS address — resolve it first
    account = api.get("getAccount", account=at_address)
    if not ok(account):
        return None, account.get("error", "Could not find AT account")

    at_numeric_id = account.get("account")
    bal_signa = signa(account.get("balanceNQT", 0))

    result = api.get("getAT", at=at_numeric_id)
    if not ok(result):
        return None, result.get("error")

    return {
        "address": result.get("atRS", at_address),
        "name": result.get("name"),
        "description": result.get("description"),
        "balance": bal_signa,
        "finished": result.get("finished", False),
        "frozen": result.get("frozen", False),
        "creator": result.get("creatorRS"),
    }, None


# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
    import argparse
    parser = argparse.ArgumentParser(description="SignaAI AT Escrow Deployer")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "testnet"),
                        choices=["mainnet", "testnet"])
    sub = parser.add_subparsers(dest="cmd")

    # gen-preimage
    sub.add_parser("gen-preimage", help="Generate a secure random preimage + its SHA256 hash")

    # deploy
    p = sub.add_parser("deploy", help="Deploy the escrow AT contract")
    p.add_argument("payer_passphrase")
    p.add_argument("worker_address", help="Worker agent's Signum address")
    p.add_argument("deadline_block", type=int, help="Absolute block height for refund cutoff")
    p.add_argument("preimage_hex", help="32-byte hex preimage (from gen-preimage)")

    # submit
    p = sub.add_parser("submit", help="Submit preimage to AT to release payment")
    p.add_argument("submitter_passphrase")
    p.add_argument("at_address", help="AT contract address")
    p.add_argument("preimage_hex", help="The preimage to reveal")

    # info
    p = sub.add_parser("info", help="Get AT contract state and balance")
    p.add_argument("at_address")

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    if args.cmd == "gen-preimage":
        preimage, hashed = gen_preimage()
        print(f"\nPreimage (keep secret until work is verified):")
        print(f"  {preimage}")
        print(f"\nSHA256(preimage) — stored in AT:")
        print(f"  {hashed}")
        print(f"\nStore the preimage securely. Reveal it to the worker only after verifying their work.")

    elif args.cmd == "deploy":
        print(f"Deploying SignaAI Escrow AT on {args.network}...")
        result, err = deploy_at(
            args.payer_passphrase, args.worker_address,
            args.deadline_block, args.preimage_hex,
            network=args.network,
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ AT contract deployed")
            print(f"  AT address:   {result['at_address']}")
            print(f"  Deploy TX:    {result['tx_id']}")
            print(f"  Worker:       {result['worker']}")
            print(f"  Deadline:     block {result['deadline_block']}")
            print(f"  Preimage hash:{result['preimage_hash'][:32]}...")
            print(f"\n  Next: fund the AT by sending SIGNA to {result['at_address']}")
            print(f"  python3 wallet.py --network {args.network} send \"<passphrase>\" {result['at_address']} <amount>")

    elif args.cmd == "submit":
        print(f"Submitting preimage to AT {args.at_address}...")
        result, err = submit_preimage(
            args.submitter_passphrase, args.at_address,
            args.preimage_hex, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Preimage submitted")
            print(f"  TX:   {result['tx_id']}")
            print(f"  {result['note']}")

    elif args.cmd == "info":
        result, err = get_at_info(args.at_address, args.network)
        if err:
            print(f"Error: {err}")
        else:
            status = "FINISHED" if result['finished'] else ("FROZEN" if result['frozen'] else "ACTIVE")
            print(f"\nAT Contract: {result['address']}")
            print(f"  Name:     {result['name']}")
            print(f"  Creator:  {result['creator']}")
            print(f"  Balance:  {result['balance']:,.4f} SIGNA")
            print(f"  Status:   {status}")
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/board.py
content: #!/usr/bin/env python3
"""
SignaAI Task Board — protocol primitives for open task discovery.

A board is any Signum account used as an append-only event feed.
Anyone can run a board. Set SIGNAAI_BOARD to the default board address,
or pass board_address explicitly to any function.

These helpers publish and read TASK: protocol messages. They contain no
matching logic, no ranking, and no marketplace policy. That belongs in
the layer above. Builders use these primitives to construct whatever
marketplace rules they need.

Typical flow:
    1. Payer calls open_task()  → TASK:OPEN published to board
    2. Workers call claim_task() → TASK:CLAIM published to board
    3. Marketplace layer (off-chain) surfaces claims to payer
    4. Payer calls accept_claim() → TASK:ACCEPT published to board
    5. Payer calls escrow.create_escrow() with the accepted worker
    6. Existing escrow / proof / release flow handles payment

Usage:
    python3 board.py open   <passphrase> <task_hash> <capability> <amount_signa> [--deadline-hours 24]
    python3 board.py claim  <passphrase> <task_id>
    python3 board.py accept <passphrase> <task_id> <worker_address>
    python3 board.py cancel <passphrase> <task_id>
    python3 board.py tasks  [--capability research] [--limit 20]
    python3 board.py claims <task_id>
"""
import os
import sys
import hashlib
import time
sys.path.insert(0, os.path.dirname(__file__))

from signum_api import get_api, nqt, signa, ok, fee_message, ts, EXPLORER_URL
from wallet import get_my_address
from protocol import (
    build_task_open, build_task_claim, build_task_accept, build_task_cancel,
    parse_task, TaskMessage, TASK_PREFIX, ProtocolError,
)

# ── Board address resolution ──────────────────────────────────────────────────

SIGNAAI_BOARD = os.environ.get("SIGNAAI_BOARD", "")
BLOCKS_PER_HOUR = 15   # ~4-minute blocks on Signum mainnet


def _resolve_board(board_address):
    addr = board_address or SIGNAAI_BOARD
    if not addr:
        raise ValueError(
            "board_address is required. Pass it explicitly or set the "
            "SIGNAAI_BOARD environment variable to a Signum address."
        )
    return addr


def _task_id(payer_address, task_hash):
    """Deterministic but unique task ID: short hash of payer + task_hash + nanosecond timestamp."""
    raw = f"{payer_address}:{task_hash}:{time.time_ns()}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]


# ── Write helpers ─────────────────────────────────────────────────────────────

def open_task(passphrase, task_hash, capability_tag="",
              amount_signa=0, deadline_hours=24,
              task_summary="", board_address=None, network=None):
    """
    Publish a task opening to the board.

    The task body is NOT stored on-chain — only task_hash. Off-chain
    delivery of the task description happens after the payer calls
    accept_claim() and creates an escrow with the chosen worker.

    Returns:
        (task_id, tx_id)  on success
        raises ValueError / RuntimeError on failure
    """
    board = _resolve_board(board_address)
    api = get_api(network)

    payer_address, err = get_my_address(passphrase, network)
    if err:
        raise ValueError(f"Could not derive payer address: {err}")

    current_block = api.get("getBlockchainStatus").get("numberOfBlocks", 0)
    deadline_block = int(current_block) + int(deadline_hours * BLOCKS_PER_HOUR)

    task_id = _task_id(payer_address, task_hash)
    amount_nqt = nqt(amount_signa) if amount_signa else 0

    message = build_task_open(task_id, payer_address, capability_tag,
                              amount_nqt, deadline_block, task_hash,
                              task_summary=task_summary)
    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=board,
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))
    if not ok(result):
        raise RuntimeError(f"open_task failed: {result.get('error')}")

    return task_id, result["transaction"]


def claim_task(passphrase, task_id, board_address=None, network=None):
    """
    Express intent to work on an open task.

    Multiple workers may claim the same task simultaneously. The payer
    decides which claim to accept — this function makes no selection.

    Returns:
        tx_id  on success
        raises RuntimeError on failure
    """
    board = _resolve_board(board_address)
    api = get_api(network)

    worker_address, err = get_my_address(passphrase, network)
    if err:
        raise ValueError(f"Could not derive worker address: {err}")

    message = build_task_claim(task_id, worker_address)
    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=board,
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))
    if not ok(result):
        raise RuntimeError(f"claim_task failed: {result.get('error')}")

    return result["transaction"]


def accept_claim(passphrase, task_id, worker_address,
                 board_address=None, network=None):
    """
    Accept a specific worker's claim on a task.

    This publishes TASK:ACCEPT to the board — a public on-chain record.
    After calling this, call escrow.create_escrow() with the same worker
    to fund and initiate the work.

    Returns:
        tx_id  on success
        raises RuntimeError on failure
    """
    board = _resolve_board(board_address)
    api = get_api(network)

    message = build_task_accept(task_id, worker_address)
    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=board,
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))
    if not ok(result):
        raise RuntimeError(f"accept_claim failed: {result.get('error')}")

    return result["transaction"]


def cancel_task(passphrase, task_id, board_address=None, network=None):
    """
    Cancel an open task. Publishes TASK:CANCEL to the board.

    This is an advisory signal — no funds are held at the board level.
    If an escrow was already created, cancel it separately via escrow.py.

    Returns:
        tx_id  on success
        raises RuntimeError on failure
    """
    board = _resolve_board(board_address)
    api = get_api(network)

    message = build_task_cancel(task_id)
    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=board,
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))
    if not ok(result):
        raise RuntimeError(f"cancel_task failed: {result.get('error')}")

    return result["transaction"]


# ── Read helpers ──────────────────────────────────────────────────────────────

def _scan_board(board_address, network, limit):
    """Fetch recent transactions at the board address and parse TASK: messages."""
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=board_address,
                     firstIndex=0,
                     lastIndex=min(limit - 1, 499),
                     type=1)   # type 1 = messaging
    if not ok(result):
        raise RuntimeError(f"Could not read board: {result.get('error')}")

    events = []
    for tx in (result.get("transactions") or []):
        msg = tx.get("attachment", {}).get("message", "")
        if not msg.startswith(TASK_PREFIX):
            continue
        try:
            parsed = parse_task(msg)
        except ProtocolError:
            continue
        events.append({
            "tx_id":     tx.get("transaction"),
            "sender":    tx.get("senderRS", tx.get("sender", "")),
            "timestamp": ts(tx.get("timestamp", 0)),
            "block":     tx.get("block"),
            "action":    parsed.action,
            "task_id":   parsed.task_id,
            "task":      parsed,
        })
    return events


def list_tasks(board_address=None, capability_tag=None,
               limit=50, network=None):
    """
    Return TASK:OPEN events on the board, newest first.

    Each entry is a dict:
        tx_id, sender, timestamp, block, action, task_id, task (TaskMessage)

    Filter by capability_tag if provided. No marketplace logic applied —
    cancelled/accepted tasks are still returned; callers filter as needed.
    """
    board = _resolve_board(board_address)
    events = _scan_board(board, network, limit)
    opens = [e for e in events if e["action"] == "OPEN"]
    if capability_tag:
        opens = [e for e in opens if e["task"].capability_tag == capability_tag]
    return opens


def get_claims(task_id, board_address=None, limit=50, network=None):
    """
    Return all TASK:CLAIM events for a given task_id, newest first.

    Each entry is a dict:
        tx_id, sender, timestamp, block, action, task_id, task (TaskMessage)

    No selection logic — all claims are returned. The caller decides
    which claim to accept.
    """
    board = _resolve_board(board_address)
    events = _scan_board(board, network, limit)
    return [e for e in events if e["action"] == "CLAIM" and e["task_id"] == task_id]


def get_board_events(board_address=None, limit=100, network=None):
    """
    Return all TASK: events on the board, newest first.

    Useful for building an indexer or monitoring tool. Returns OPEN,
    CLAIM, ACCEPT, and CANCEL events together — no filtering.
    """
    board = _resolve_board(board_address)
    return _scan_board(board, network, limit)


# ── CLI ───────────────────────────────────────────────────────────────────────

def main():
    import argparse
    parser = argparse.ArgumentParser(description="SignaAI Task Board")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "mainnet"),
                        choices=["mainnet", "testnet"])
    parser.add_argument("--board", default=None,
                        help="Board address (overrides SIGNAAI_BOARD env var)")
    sub = parser.add_subparsers(dest="cmd")

    p = sub.add_parser("open", help="Publish a task opening")
    p.add_argument("passphrase")
    p.add_argument("task_hash", help="SHA-256 of the task body (off-chain)")
    p.add_argument("capability", nargs="?", default="",
                   help="Capability tag, e.g. research, code, data")
    p.add_argument("amount", nargs="?", type=float, default=0,
                   help="Offered payment in SIGNA")
    p.add_argument("--deadline-hours", type=int, default=24)
    p.add_argument("--summary", default="", help="Short public description (max 200 chars)")

    p = sub.add_parser("claim", help="Claim an open task")
    p.add_argument("passphrase")
    p.add_argument("task_id")

    p = sub.add_parser("accept", help="Accept a worker's claim")
    p.add_argument("passphrase")
    p.add_argument("task_id")
    p.add_argument("worker_address")

    p = sub.add_parser("cancel", help="Cancel an open task")
    p.add_argument("passphrase")
    p.add_argument("task_id")

    p = sub.add_parser("tasks", help="List open tasks on the board")
    p.add_argument("--capability", default=None)
    p.add_argument("--limit", type=int, default=20)

    p = sub.add_parser("claims", help="List claims for a task")
    p.add_argument("task_id")
    p.add_argument("--limit", type=int, default=20)

    p = sub.add_parser("events", help="Show all board events")
    p.add_argument("--limit", type=int, default=50)

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    board = args.board if hasattr(args, "board") else None

    try:
        if args.cmd == "open":
            task_id, tx_id = open_task(
                args.passphrase, args.task_hash, args.capability,
                args.amount, args.deadline_hours,
                getattr(args, "summary", ""), board, args.network)
            print(f"Task opened")
            print(f"  Task ID: {task_id}")
            print(f"  TX:      {tx_id}")
            print(f"  View:    {EXPLORER_URL}/tx/{tx_id}")

        elif args.cmd == "claim":
            tx_id = claim_task(args.passphrase, args.task_id, board, args.network)
            print(f"Claim submitted — TX: {tx_id}")

        elif args.cmd == "accept":
            tx_id = accept_claim(args.passphrase, args.task_id,
                                 args.worker_address, board, args.network)
            print(f"Claim accepted — worker: {args.worker_address}  TX: {tx_id}")
            print(f"Next step: create escrow with this worker via escrow.py")

        elif args.cmd == "cancel":
            tx_id = cancel_task(args.passphrase, args.task_id, board, args.network)
            print(f"Task cancelled — TX: {tx_id}")

        elif args.cmd == "tasks":
            tasks = list_tasks(board, args.capability, args.limit, args.network)
            if not tasks:
                print("No open tasks found.")
            else:
                print(f"{'TIMESTAMP':<18} {'TASK ID':<18} {'CAP':<12} {'SIGNA':>8}  PAYER")
                print("-" * 72)
                for e in tasks:
                    t = e["task"]
                    summary = f"  {t.task_summary}" if t.task_summary else ""
                    print(f"{e['timestamp']:<18} {t.task_id:<18} "
                          f"{t.capability_tag:<12} {signa(t.amount_nqt):>8.2f}  {t.payer_address}{summary}")

        elif args.cmd == "claims":
            claims = get_claims(args.task_id, board, args.limit, args.network)
            if not claims:
                print(f"No claims found for task {args.task_id}")
            else:
                print(f"Claims for task {args.task_id}:")
                for e in claims:
                    print(f"  {e['timestamp']}  {e['task'].worker_address}  TX: {e['tx_id']}")

        elif args.cmd == "events":
            events = get_board_events(board, args.limit, args.network)
            if not events:
                print("No board events found.")
            else:
                for e in events:
                    print(f"{e['timestamp']}  {e['action']:<8} {e['task_id']}  {e['sender']}")

        else:
            parser.print_help()

    except (ValueError, RuntimeError) as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/escrow.py
content: #!/usr/bin/env python3
"""
Signum Agent Escrow — AT-backed trustless task payment system

State machine:
  CREATED → SUBMITTED → RELEASED
                      → REFUNDED (if deadline passed, AT auto-refunds)

Funds flow:
  create  → payer deploys AT contract → funds AT address (money leaves payer's wallet)
  release → payer submits preimage to AT → AT auto-releases to worker

All state transitions are recorded as on-chain messages — fully auditable.
AT holds funds trustlessly; once preimage is submitted, payment cannot be cancelled.

On-chain message format:
  ESCROW:CREATE:<escrow_id>:<worker>:<amount_nqt>:<task_hash>:<deadline_block>:<at_address>
  ESCROW:SUBMIT:<escrow_id>:<result_hash>:<proof_tx>
  ESCROW:RELEASE:<escrow_id>:<worker>
  ESCROW:REFUND:<escrow_id>:<payer>

Usage:
  python3 escrow.py create <payer_passphrase> <worker_address> <amount> "<task_description>" [--deadline-hours 24]
  python3 escrow.py submit <worker_passphrase> <escrow_id> "<result_content>" [--sources "url1,url2"]
  python3 escrow.py release <payer_passphrase> <escrow_id>
  python3 escrow.py refund <payer_passphrase> <escrow_id>
  python3 escrow.py status <escrow_id>
"""
import sys
import os
import json
import hashlib
import secrets
import time
sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, signa, nqt, ts, fee_message, FEE_STANDARD, ok
from wallet import get_my_address, send_signa, get_transactions
from verify import hash_content, publish_proof
from _deploy_at import deploy_at as _at_deploy, submit_preimage as _at_submit, gen_preimage
from protocol import (
    build_escrow_create, build_escrow_fund, build_escrow_assign,
    build_escrow_submit, build_escrow_release, build_escrow_refund,
    parse_message, EscrowMessage,
)

# ── Constants ─────────────────────────────────────────────────────────────────
ESCROW_PREFIX    = "ESCROW:"
BLOCKS_PER_HOUR  = 15  # ~4 min per block = 15 blocks/hour
DEDUP_FILE       = os.path.expanduser("~/.openclaw/workspace/signaai-escrow-dedup.json")
DEDUP_TTL        = 3600  # seconds — ignore duplicate requests within 1 hour
RELEASE_LOG_FILE      = os.path.expanduser("~/.openclaw/workspace/signaai-release-log.json")
PENDING_RELEASES_FILE = os.path.expanduser("~/.openclaw/workspace/signaai-pending-releases.json")
PREIMAGE_DIR          = os.path.expanduser("~/.signaai/preimages")
RECEIPT_FILE          = os.path.expanduser("~/.openclaw/workspace/signaai-last-escrow-receipt.txt")

# Escrow states
STATE_CREATED   = "CREATED"
STATE_SUBMITTED = "SUBMITTED"
STATE_RELEASED  = "RELEASED"
STATE_REFUNDED  = "REFUNDED"


# ── Helpers ───────────────────────────────────────────────────────────────────

def _dedup_check(task_description):
    """
    Returns existing escrow_id if this task was already created within DEDUP_TTL.
    Returns "pending" if creation is in progress (race condition guard).
    Returns None if this is a new task — safe to proceed.
    """
    task_key = hashlib.sha256(task_description.strip().lower().encode()).hexdigest()[:16]
    try:
        if os.path.exists(DEDUP_FILE):
            with open(DEDUP_FILE) as f:
                dedup = json.load(f)
            entry = dedup.get(task_key)
            if entry:
                age = time.time() - entry["created_at"]
                ttl = 1800 if entry["escrow_id"] == "pending" else DEDUP_TTL
                if age < ttl:
                    return entry["escrow_id"]  # may be "pending" or real ID
    except Exception:
        pass
    return None

def _dedup_record(task_description, escrow_id):
    """Record a newly created escrow to prevent duplicates."""
    task_key = hashlib.sha256(task_description.strip().lower().encode()).hexdigest()[:16]
    try:
        dedup = {}
        if os.path.exists(DEDUP_FILE):
            with open(DEDUP_FILE) as f:
                dedup = json.load(f)
        # Prune old entries
        now = time.time()
        dedup = {k: v for k, v in dedup.items() if now - v["created_at"] < DEDUP_TTL}
        dedup[task_key] = {"escrow_id": escrow_id, "created_at": now}
        os.makedirs(os.path.dirname(DEDUP_FILE), exist_ok=True)
        tmp = DEDUP_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(dedup, f, indent=2)
        os.replace(tmp, DEDUP_FILE)
    except Exception:
        pass

def _release_check(escrow_id):
    """Return existing release TX if this escrow was already released locally. None if safe to proceed."""
    try:
        if os.path.exists(RELEASE_LOG_FILE):
            with open(RELEASE_LOG_FILE) as f:
                log = json.load(f)
            entry = log.get(escrow_id)
            if entry:
                return entry.get("tx_id")
    except Exception:
        pass
    return None

def _release_record(escrow_id, tx_id):
    """Record a completed release so repeat calls are blocked instantly."""
    try:
        log = {}
        if os.path.exists(RELEASE_LOG_FILE):
            with open(RELEASE_LOG_FILE) as f:
                log = json.load(f)
        log[escrow_id] = {"tx_id": tx_id, "released_at": time.time()}
        os.makedirs(os.path.dirname(RELEASE_LOG_FILE), exist_ok=True)
        tmp = RELEASE_LOG_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(log, f, indent=2)
        os.replace(tmp, RELEASE_LOG_FILE)
    except Exception:
        pass


def _save_pending_release(escrow_id, at_address, worker, amount_signa, preimage_tx):
    """Write a pending-release entry so the listener can watch for the AT payout."""
    try:
        os.makedirs(os.path.dirname(PENDING_RELEASES_FILE), exist_ok=True)
        entries = []
        if os.path.exists(PENDING_RELEASES_FILE):
            try:
                with open(PENDING_RELEASES_FILE) as f:
                    entries = json.load(f)
            except Exception:
                entries = []
        entries = [e for e in entries if e.get("escrow_id") != escrow_id]
        entries.append({
            "escrow_id":    escrow_id,
            "at_address":   at_address,
            "worker":       worker,
            "amount_signa": float(amount_signa),
            "preimage_tx":  str(preimage_tx),
            "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
        })
        tmp = PENDING_RELEASES_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(entries, f, indent=2)
        os.replace(tmp, PENDING_RELEASES_FILE)
    except Exception:
        pass

def _format_escrow_receipt(escrow, deadline_hours=None):
    """Return the canonical receipt shown to the payer after escrow creation."""
    amount = escrow.get("amount_signa", 0)
    amount_text = f"{float(amount):g}"

    if deadline_hours is None:
        create_height = int(escrow.get("create_height") or escrow.get("current_block") or 0)
        deadline_block = int(escrow.get("deadline_block") or 0)
        if create_height and deadline_block > create_height:
            deadline_hours = round((deadline_block - create_height) / BLOCKS_PER_HOUR)
        else:
            deadline_hours = 24
    deadline_text = f"{float(deadline_hours):g}"

    return (
        "Escrow created:\n"
        f"ID: {escrow['escrow_id']}\n"
        f"Record TX: {escrow.get('record_tx') or escrow.get('create_tx')}\n"
        f"Fund TX: {escrow.get('fund_tx')}\n\n"
        f"Task sent to worker ({amount_text} SIGNA, {deadline_text}h deadline). "
        "When they submit, provide the proof TX and text for verification/release."
    )

def _store_last_receipt(receipt):
    """Persist the last create receipt so OpenClaw can recover it if stdout is lost."""
    try:
        os.makedirs(os.path.dirname(RECEIPT_FILE), exist_ok=True)
        tmp = RECEIPT_FILE + ".tmp"
        with open(tmp, "w") as f:
            f.write(receipt.rstrip() + "\n")
        os.replace(tmp, RECEIPT_FILE)
    except Exception:
        pass


def _load_tg_config():
    """Read Telegram bot token and chat ID from OpenClaw config."""
    try:
        cfg_path = os.path.expanduser("~/.openclaw/openclaw.json")
        with open(cfg_path) as f:
            cfg = json.load(f)
        tg = cfg.get("channels", {}).get("telegram", {})
        [REDACTED_SECRET]"botToken", "") or None
        approvers = tg.get("execApprovals", {}).get("approvers", [])
        chat_id = approvers[0] if approvers else None
        return token, chat_id
    except Exception:
        return None, None


def _send_telegram(token, chat_id, message):
    """Send a Telegram message. Silently no-ops if not configured."""
    if not token or not chat_id:
        return
    import urllib.request, urllib.parse
    try:
        url = f"https://api.telegram.org/bot{token}/sendMessage"
        payload = {"chat_id": chat_id, "text": message, "parse_mode": "Markdown"}
        data = urllib.parse.urlencode(payload).encode()
        urllib.request.urlopen(urllib.request.Request(url, data=data), timeout=10)
    except Exception:
        try:
            payload = {"chat_id": chat_id, "text": message}
            data = urllib.parse.urlencode(payload).encode()
            urllib.request.urlopen(urllib.request.Request(url, data=data), timeout=10)
        except Exception:
            pass


def _store_preimage(escrow_id, preimage, at_address, deploy_tx):
    """Store preimage in a per-escrow file with 0o600 permissions. Never goes on-chain."""
    try:
        os.makedirs(PREIMAGE_DIR, exist_ok=True)
        path = os.path.join(PREIMAGE_DIR, f"{escrow_id}.json")
        tmp  = path + ".tmp"
        with open(tmp, "w") as f:
            json.dump({
                "preimage":   preimage,
                "at_address": at_address,
                "deploy_tx":  deploy_tx,
                "created_at": time.time(),
            }, f, indent=2)
        os.replace(tmp, path)
        os.chmod(path, 0o600)
        os.chmod(PREIMAGE_DIR, 0o700)
    except Exception as e:
        print(f"  Warning: could not store preimage: {e}")


def _load_preimage(escrow_id):
    """Load stored preimage data for an escrow. Returns {} if not found."""
    try:
        path = os.path.join(PREIMAGE_DIR, f"{escrow_id}.json")
        if os.path.exists(path):
            with open(path) as f:
                return json.load(f)
    except Exception:
        pass
    return {}

def _find_at_payout(api, at_address, worker_address, limit=99):
    """Return an AT payout transaction to the worker, if one is visible."""
    if not at_address or not worker_address:
        return None

    at_account = api.get("getAccount", account=at_address)
    at_numeric = str(at_account.get("account", ""))

    worker_txs = api.get("getAccountTransactions",
                         account=worker_address,
                         firstIndex=0,
                         lastIndex=limit)
    for tx in (worker_txs.get("transactions") or []):
        sender_rs = tx.get("senderRS", "")
        sender_id = str(tx.get("sender", ""))
        amount_nqt = int(tx.get("amountNQT") or 0)
        if amount_nqt <= 0:
            continue
        if sender_rs == at_address or (at_numeric and sender_id == at_numeric):
            return {
                "tx_id": tx.get("transaction"),
                "amount_nqt": amount_nqt,
                "height": tx.get("height"),
            }
    return None

def _find_at_preimage_submission(api, at_address, sender_address, preimage, limit=99):
    """Return a previous preimage submission TX to the AT, if one is visible."""
    if not at_address or not sender_address or not preimage:
        return None

    sender_txs = api.get("getAccountTransactions",
                         account=sender_address,
                         firstIndex=0,
                         lastIndex=limit)
    for tx in (sender_txs.get("transactions") or []):
        msg = tx.get("attachment", {}).get("message", "")
        if tx.get("recipientRS") == at_address and msg == preimage:
            return tx.get("transaction")
    return None


def _check_onchain_escrow(escrow_id, address, network):
    """Return existing escrow dict if this escrow_id already exists on-chain. None if new."""
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex=0,
                     lastIndex=49)
    txs = [tx for tx in (result.get("transactions") or [])
           if f"ESCROW:CREATE:{escrow_id}" in tx.get("attachment", {}).get("message", "")]
    if not txs:
        return None
    escrow, _ = _parse_escrow_from_txs(escrow_id, txs)
    preimage_data = _load_preimage(escrow_id)
    if preimage_data.get("at_address"):
        escrow["at_address"] = preimage_data["at_address"]
        escrow["deploy_tx"]  = preimage_data.get("deploy_tx", "")
    return escrow if escrow.get("payer") else None


def _read_telegram_config():
    """Read payer's Telegram bot token and chat ID from openclaw.json."""
    try:
        cfg_path = os.path.expanduser("~/.openclaw/openclaw.json")
        with open(cfg_path) as f:
            cfg = json.load(f)
        tg = cfg.get("channels", {}).get("telegram", {})
        [REDACTED_SECRET]"botToken", "") or ""
        approvers = tg.get("execApprovals", {}).get("approvers", [])
        chat_id = str(approvers[0]) if approvers else ""
        return token, chat_id
    except Exception:
        return "", ""


# ── Core Functions ────────────────────────────────────────────────────────────

def create_escrow(payer_passphrase, worker_address, amount_signa,
                  task_description, deadline_hours=24, network=None):
    """
    Create an escrow agreement on-chain.

    Steps:
    1. Generate a unique escrow ID
    2. Compute task hash (what the worker must deliver)
    3. Record escrow creation on-chain (payer → self message)
    4. Transfer funds to escrow operator wallet

    Returns escrow_id and full escrow record.
    """
    if not payer_passphrase or not str(payer_passphrase).strip():
        return None, "Payer passphrase cannot be empty"

    # Hard dedup — refuse to create a second escrow for the same task within 1 hour
    # Write "pending" BEFORE any blockchain calls — prevents race condition with parallel tool calls
    existing = _dedup_check(task_description)
    if existing and existing != "pending":
        print(f"  Duplicate detected — escrow {existing} already created for this task.")
        return {"escrow_id": existing, "duplicate": True}, None
    if existing == "pending":
        return None, "Escrow creation already in progress for this task — wait and retry."
    _dedup_record(task_description, "pending")  # reserve slot immediately

    api = get_api(network)

    # Get payer address
    payer_address, err = get_my_address(payer_passphrase, network)
    if err:
        return None, f"Could not derive payer address: {err}"

    # Get current block for deadline calculation
    status = api.get("getBlockchainStatus")
    if not ok(status):
        return None, "Could not get blockchain status"

    current_block = int(status.get("numberOfBlocks", 0))
    deadline_block = current_block + (deadline_hours * BLOCKS_PER_HOUR)

    # Hash the task description — this is what the worker must reference
    task_hash = hashlib.sha256(task_description.encode()).hexdigest()

    # Deterministic escrow_id: survives machine restarts and dedup file loss
    # Buckets to nearest hour so retries within the same hour produce the same ID
    timestamp_bucket = int(time.time() // 3600) * 3600
    escrow_id = hashlib.sha256(
        f"{payer_address}:{task_hash}:{timestamp_bucket}".encode()
    ).hexdigest()[:16]

    # On-chain idempotency: check if this escrow already exists (survives local dedup loss)
    onchain = _check_onchain_escrow(escrow_id, payer_address, network)
    if onchain:
        print(f"  On-chain escrow {escrow_id} already exists — returning it.")
        _dedup_record(task_description, escrow_id)
        return onchain, None

    try:
        amount_nqt = nqt(amount_signa)
    except ValueError as exc:
        return None, str(exc)
    if amount_nqt <= 0:
        return None, "Amount must be greater than zero"

    # Generate preimage for AT — kept secret until release
    preimage, _ = gen_preimage()

    # Emit escrow ID immediately — Claude may time out during the 4-min AT wait,
    # so print the ID before blocking so the agent can relay it to the user.
    print(f"Escrow ID: {escrow_id}", flush=True)
    print(f"(Deploying AT contract — takes ~4 minutes for block confirmation)", flush=True)

    # Step 1: Deploy AT contract — funds will be held trustlessly until release
    # AT auto-refunds to payer after deadline_block if worker never submits
    print(f"  Deploying AT escrow contract (takes ~4 min for block confirmation)...", flush=True)
    at_result, err = _at_deploy(
        payer_passphrase, worker_address,
        deadline_block, preimage,
        escrow_id=escrow_id, network=network
    )
    if err:
        return None, f"AT deployment failed: {err}"

    at_address = at_result["at_address"]
    deploy_tx  = at_result["tx_id"]

    # Step 2: Fund AT — money leaves payer's wallet into the AT contract
    print(f"  Funding AT {at_address} with {amount_signa} SIGNA...")
    fund_message = build_escrow_fund(escrow_id)
    fund_tx, err = send_signa(payer_passphrase, at_address, amount_signa,
                              message=fund_message, network=network)
    if err:
        return None, f"Failed to fund AT: {err}"

    # Store preimage locally — used at release time, never goes on-chain
    _store_preimage(escrow_id, preimage, at_address, deploy_tx)

    # Step 3: Record escrow creation on-chain (payer → self audit record)
    print(f"  Recording escrow on-chain...")
    time.sleep(2)
    message = build_escrow_create(escrow_id, worker_address, amount_nqt,
                                   task_hash, deadline_block, operator=at_address)
    record_result = api.post("sendMessage",
                             secretPhrase=payer_passphrase,
                             recipient=payer_address,
                             message=message,
                             messageIsText="true",
                             feeNQT=fee_message(message))
    if not ok(record_result):
        return None, f"Failed to record escrow: {record_result.get('error')}"
    record_tx = record_result.get("transaction")

    # Step 4a: Notify the worker via on-chain CREATE message (includes task description)
    # Primary notification path — worker listener handles ESCROW:CREATE with worker=their address
    print(f"  Sending CREATE to worker...")
    time.sleep(2)
    worker_create_msg = build_escrow_create(escrow_id, worker_address, amount_nqt,
                                             task_hash, deadline_block,
                                             operator=at_address,
                                             task_description=task_description[:900])
    create_result = api.post("sendMessage",
                              secretPhrase=payer_passphrase,
                              recipient=worker_address,
                              message=worker_create_msg,
                              messageIsText="true",
                              feeNQT=fee_message(worker_create_msg))
    if not ok(create_result):
        print(f"  Warning: worker CREATE failed: {create_result.get('error')} — falling back to ASSIGN")

    # Step 4b: Also send ASSIGN to worker (backward-compatible fallback)
    print(f"  Sending ASSIGN to worker...")
    time.sleep(2)
    notify_message = build_escrow_assign(escrow_id, task_hash,
                                          task_description[:900])
    assign_result = api.post("sendMessage",
                              secretPhrase=payer_passphrase,
                              recipient=worker_address,
                              message=notify_message,
                              messageIsText="true",
                              feeNQT=fee_message(notify_message))
    if not ok(assign_result):
        assign_err = assign_result.get("error", "unknown error")
        if not ok(create_result):
            return None, f"Failed to notify worker (both CREATE and ASSIGN failed): {assign_err}"
        print(f"  Warning: ASSIGN failed ({assign_err}) — worker will be notified via CREATE")

    escrow = {
        "escrow_id":   escrow_id,
        "state":       STATE_CREATED,
        "payer":       payer_address,
        "worker":      worker_address,
        "amount_signa": amount_signa,
        "task_description": task_description,
        "task_hash":   task_hash,
        "deadline_block": deadline_block,
        "at_address":  at_address,
        "deploy_tx":   deploy_tx,
        "record_tx":   record_tx,
        "fund_tx":     fund_tx,
        "current_block": current_block,
    }

    # Record in dedup file — prevents duplicate creation even if script is called again
    _dedup_record(task_description, escrow_id)

    return escrow, None


def submit_result(worker_passphrase, escrow_id, result_content,
                  sources=None, recipient_address=None, network=None):
    """
    Worker submits completed task result on-chain.

    1. Hash the result content
    2. Publish hash to Signum (verifiable proof)
    3. Send submission message to escrow record
    """
    if not worker_passphrase or not str(worker_passphrase).strip():
        return None, "Worker passphrase cannot be empty"

    api = get_api(network)

    worker_address, err = get_my_address(worker_passphrase, network)
    if err:
        return None, err

    # Hash the result
    hashes = hash_content(result_content, sources)
    result_hash = hashes["content_hash"]

    # Publish proof on-chain first
    print(f"  Publishing result proof on-chain...")
    proof, err = publish_proof(worker_passphrase, result_hash,
                               hashes["sources_hash"],
                               label=f"escrow-{escrow_id}",
                               network=network)
    if err:
        return None, f"Failed to publish proof: {err}"

    time.sleep(2)

    submission, err = submit_proof(
        worker_passphrase, escrow_id, result_hash, proof["tx_id"],
        recipient_address=recipient_address, network=network
    )
    if err:
        return None, err
    submit_tx = submission["submit_tx"]

    return {
        "escrow_id": escrow_id,
        "worker": worker_address,
        "result_hash": result_hash,
        "proof_tx": proof["tx_id"],
        "submit_tx": submit_tx,
        "state": STATE_SUBMITTED,
    }, None


def submit_proof(worker_passphrase, escrow_id, result_hash, proof_tx,
                 recipient_address=None, network=None):
    """
    Submit an already-stamped result proof to escrow without stamping again.

    The autonomous listener stamps and self-verifies first, then calls this so
    a successful task produces one SIGPROOF and one ESCROW:SUBMIT.
    """
    if not worker_passphrase or not str(worker_passphrase).strip():
        return None, "Worker passphrase cannot be empty"
    if not result_hash:
        return None, "result_hash is required"
    if not proof_tx:
        return None, "proof_tx is required"

    api = get_api(network)
    worker_address, err = get_my_address(worker_passphrase, network)
    if err:
        return None, err

    recipient_address = str(recipient_address or worker_address).strip()
    if not recipient_address:
        return None, "recipient_address cannot be empty"

    message = build_escrow_submit(escrow_id, result_hash, proof_tx)
    print(f"  Submitting result to escrow...")
    submit_result_tx = api.post("sendMessage",
                                secretPhrase=worker_passphrase,
                                recipient=recipient_address,
                                message=message,
                                messageIsText="true",
                                feeNQT=fee_message(message))
    if not ok(submit_result_tx):
        return None, f"Failed to submit result: {submit_result_tx.get('error')}"

    return {
        "escrow_id": escrow_id,
        "worker": worker_address,
        "recipient": recipient_address,
        "result_hash": result_hash,
        "proof_tx": proof_tx,
        "submit_tx": submit_result_tx.get("transaction"),
        "state": STATE_SUBMITTED,
    }, None


def release_payment(operator_passphrase, escrow_id, network=None):
    """
    Release escrow funds to worker after verifying result hash matches.
    Called by operator after confirming submission is valid.

    In AT version: this happens automatically when hash matches.
    """
    if not operator_passphrase or not str(operator_passphrase).strip():
        return None, "Operator passphrase cannot be empty"

    # Local dedup — no network call needed, blocks repeat releases instantly
    existing_tx = _release_check(escrow_id)
    if existing_tx:
        return None, f"Escrow already released — TX: {existing_tx}. Nothing to do."

    api = get_api(network)

    operator_address, err = get_my_address(operator_passphrase, network)
    if err:
        return None, err

    # Secondary check: on-chain state
    escrow_data, err = get_escrow_status(escrow_id, address=operator_address, network=network)
    if err:
        return None, err

    worker = escrow_data.get("worker")
    amount = escrow_data.get("amount_signa", 0)

    if not worker:
        return None, "Could not determine worker address from escrow record"

    preimage_data = _load_preimage(escrow_id)
    preimage   = preimage_data.get("preimage")
    at_address = escrow_data.get("at_address") or preimage_data.get("at_address")

    if at_address:
        payout = _find_at_payout(api, at_address, worker)
        if payout:
            _release_record(escrow_id, payout["tx_id"])  # backfill local log
            return None, f"Escrow already released by AT - TX: {payout['tx_id']}. Nothing to do."

        pending_release_tx = _find_at_preimage_submission(
            api, at_address, operator_address, preimage
        )
        if pending_release_tx:
            return None, (f"AT release already submitted - TX: {pending_release_tx}. "
                          "Waiting for AT payout.")

    if escrow_data["state"] == STATE_RELEASED:
        release_tx = escrow_data.get("release_tx", "unknown")
        _release_record(escrow_id, release_tx)  # backfill local log
        return None, f"Escrow already released — TX: {release_tx}. Nothing to do."

    if escrow_data["state"] == STATE_REFUNDED:
        return None, f"Escrow already refunded. Nothing to do."

    if escrow_data["state"] != STATE_SUBMITTED:
        return None, f"Escrow not in SUBMITTED state (current: {escrow_data['state']})"

    # Verify proof TX hash matches submitted hash — no full text needed
    submitted_hash = escrow_data.get("submitted_hash", "")
    proof_tx_id = escrow_data.get("proof_tx", "")

    if submitted_hash and proof_tx_id:
        print(f"  Verifying proof hash on-chain...")
        proof_tx = api.get("getTransaction", transaction=proof_tx_id)
        if ok(proof_tx):
            proof_msg = proof_tx.get("attachment", {}).get("message", "")
            # SIGPROOF:v1:<content_hash>:<sources_hash>
            parts = proof_msg.split(":")
            onchain_hash = parts[2] if len(parts) > 2 else ""
            if onchain_hash and submitted_hash != onchain_hash:
                return None, (f"Hash mismatch — submitted: {submitted_hash[:16]}... "
                              f"on-chain proof: {onchain_hash[:16]}... "
                              f"Worker's proof TX does not match their submission.")
            print(f"  Hash verified ✓")
        else:
            print(f"  Warning: could not fetch proof TX {proof_tx_id} — skipping hash check")

    if preimage and at_address:
        # AT release: submit preimage → AT verifies hash → auto-releases to worker
        # Once submitted, payer cannot cancel — AT executes on next block
        print(f"  Submitting preimage to AT {at_address}...")
        at_result, err = _at_submit(operator_passphrase, at_address, preimage, network)
        if err:
            return None, f"AT release failed: {err}"
        tx_id = at_result["tx_id"]
        print(f"  Preimage submitted — AT will release {amount} SIGNA to worker on next block (~4 min)")
        _save_pending_release(escrow_id, at_address, worker, amount, tx_id)
        state = "PREIMAGE_SUBMITTED"
    else:
        # Phase 1 fallback: direct payment (legacy escrows without AT)
        print(f"  No AT found for this escrow — using direct payment...")
        print(f"  Releasing {amount} SIGNA to {worker}...")
        release_message = build_escrow_release(escrow_id, worker)
        tx_id, err = send_signa(operator_passphrase, worker, amount,
                                message=release_message, network=network)
        if err:
            return None, f"Release failed: {err}"
        # Direct payment has actually moved funds, so dedup future releases.
        _release_record(escrow_id, tx_id)
        state = STATE_RELEASED

    return {
        "escrow_id":  escrow_id,
        "state":      state,
        "worker":     worker,
        "amount_signa": amount,
        "tx_id":      tx_id,
        "at_address": at_address or "",
    }, None


def refund_escrow(operator_passphrase, escrow_id, network=None):
    """
    Refund escrow to payer if deadline has passed without valid submission.
    """
    api = get_api(network)

    operator_address, err = get_my_address(operator_passphrase, network)
    if err:
        return None, err

    escrow_data, err = get_escrow_status(escrow_id, address=operator_address, network=network)
    if err:
        return None, err

    if escrow_data["state"] == STATE_RELEASED:
        return None, "Escrow already released — cannot refund"

    # Check deadline
    status = api.get("getBlockchainStatus")
    current_block = int(status.get("numberOfBlocks", 0))
    deadline_block = escrow_data.get("deadline_block", 0)

    if current_block < deadline_block and escrow_data["state"] != STATE_CREATED:
        blocks_left = deadline_block - current_block
        hours_left = blocks_left / BLOCKS_PER_HOUR
        return None, f"Deadline not reached — {blocks_left} blocks ({hours_left:.1f}h) remaining"

    payer = escrow_data.get("payer")
    amount = escrow_data.get("amount_signa", 0)

    if not payer:
        return None, "Could not determine payer address"

    print(f"  Refunding {amount} SIGNA to {payer}...")
    refund_message = build_escrow_refund(escrow_id, payer)
    tx_id, err = send_signa(operator_passphrase, payer, amount,
                            message=refund_message, network=network)
    if err:
        return None, f"Refund failed: {err}"

    return {
        "escrow_id": escrow_id,
        "state": STATE_REFUNDED,
        "payer": payer,
        "amount_signa": amount,
        "tx_id": tx_id,
    }, None


def get_escrow_status(escrow_id, address=None, network=None):
    """
    Reconstruct escrow state from on-chain messages.
    Scans transaction history for ESCROW: messages matching the escrow_id.
    """
    api = get_api(network)

    # If no address provided, use the dev wallet (escrow operator)
    # In production: query the AT contract directly
    if not address:
        # Try to find the escrow in recent transactions
        # For prototype, we scan a known address
        return _scan_for_escrow(escrow_id, network)

    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex=0,
                     lastIndex=99)

    txs = result.get("transactions", [])
    escrow, err = _parse_escrow_from_txs(escrow_id, txs)

    # If we found a worker address different from the scanned address,
    # also scan the worker's transactions to pick up SUBMIT messages
    worker = escrow.get("worker")
    if worker and worker != address and escrow.get("state") == STATE_CREATED:
        worker_result = api.get("getAccountTransactions",
                                account=worker,
                                firstIndex=0,
                                lastIndex=99)
        worker_txs = worker_result.get("transactions", [])
        escrow2, _ = _parse_escrow_from_txs(escrow_id, worker_txs)
        # Merge — keep base data from first scan, take higher state from worker scan
        if escrow2.get("state") in (STATE_SUBMITTED, STATE_RELEASED, STATE_REFUNDED):
            escrow.update({k: v for k, v in escrow2.items()
                           if k not in ("payer", "worker", "amount_nqt", "amount_signa",
                                        "task_hash", "deadline_block", "create_tx", "created_at")})

    if escrow.get("at_address") and escrow.get("worker") and escrow.get("state") in (STATE_CREATED, STATE_SUBMITTED):
        payout = _find_at_payout(api, escrow["at_address"], escrow["worker"])
        if payout:
            escrow["state"] = STATE_RELEASED
            escrow["release_tx"] = payout["tx_id"]
            escrow["released_amount_nqt"] = payout["amount_nqt"]
            escrow["released_at_height"] = payout["height"]

    return escrow, err


def _scan_for_escrow(escrow_id, network=None):
    """Scan recent chain transactions for an escrow record."""
    api = get_api(network)

    # This is a simplified scan — in production use an indexed store
    result = api.get("getUnconfirmedTransactions")
    txs = result.get("unconfirmedTransactions", [])

    state, err = _parse_escrow_from_txs(escrow_id, txs)
    if state and state.get("state"):
        return state, None

    return {"escrow_id": escrow_id, "state": "UNKNOWN",
            "note": "Provide --address to scan a specific account"}, None


def _parse_escrow_from_txs(escrow_id, transactions):
    """Parse escrow state from a list of transactions."""
    # State priority — higher index wins
    STATE_RANK = {STATE_CREATED: 0, STATE_SUBMITTED: 1,
                  STATE_RELEASED: 2, STATE_REFUNDED: 2}

    escrow = {"escrow_id": escrow_id, "state": "UNKNOWN"}
    best_rank = -1

    for tx in transactions:
        msg = tx.get("attachment", {}).get("message", "")
        if not msg.startswith(ESCROW_PREFIX):
            continue
        if escrow_id not in msg:
            continue

        try:
            parsed = parse_message(msg)
        except Exception:
            continue
        if not isinstance(parsed, EscrowMessage):
            continue
        if parsed.escrow_id != escrow_id:
            continue

        action = parsed.action

        if action == "CREATE":
            escrow.update({
                "payer":          tx.get("senderRS"),
                "worker":         parsed.worker,
                "amount_nqt":     parsed.amount_nqt,
                "amount_signa":   parsed.amount_nqt / 100_000_000,
                "task_hash":      parsed.task_hash,
                "deadline_block": parsed.deadline_block,
                "at_address":     parsed.operator,
                "create_tx":      tx.get("transaction"),
                "record_tx":      tx.get("transaction"),
                "create_height":  tx.get("height"),
                "created_at":     ts(tx.get("timestamp")),
            })
            if STATE_RANK[STATE_CREATED] > best_rank:
                escrow["state"] = STATE_CREATED
                best_rank = STATE_RANK[STATE_CREATED]
        elif action == "FUND":
            escrow.update({
                "fund_tx":   tx.get("transaction"),
                "funded_at": ts(tx.get("timestamp")),
            })
        elif action == "SUBMIT":
            if STATE_RANK[STATE_SUBMITTED] > best_rank:
                escrow.update({
                    "state":        STATE_SUBMITTED,
                    "submitted_hash": parsed.result_hash,
                    "proof_tx":     parsed.proof_tx,
                    "submit_tx":    tx.get("transaction"),
                    "submitted_at": ts(tx.get("timestamp")),
                    "submitted_by": tx.get("senderRS"),
                })
                best_rank = STATE_RANK[STATE_SUBMITTED]
        elif action == "RELEASE":
            if STATE_RANK[STATE_RELEASED] > best_rank:
                escrow.update({
                    "state":       STATE_RELEASED,
                    "release_tx":  tx.get("transaction"),
                    "released_at": ts(tx.get("timestamp")),
                })
                best_rank = STATE_RANK[STATE_RELEASED]
        elif action == "REFUND":
            if STATE_RANK[STATE_REFUNDED] > best_rank:
                escrow.update({
                    "state":       STATE_REFUNDED,
                    "refund_tx":   tx.get("transaction"),
                    "refunded_at": ts(tx.get("timestamp")),
                })
                best_rank = STATE_RANK[STATE_REFUNDED]

    return escrow, None


# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
    import argparse
    parser = argparse.ArgumentParser(description="Signum Agent Escrow")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "testnet"),
                        choices=["mainnet", "testnet"])
    sub = parser.add_subparsers(dest="cmd")

    # create
    p = sub.add_parser("create", help="Create a new escrow")
    p.add_argument("payer_passphrase")
    p.add_argument("worker_address")
    p.add_argument("amount", type=float, help="SIGNA amount")
    p.add_argument("task_description")
    p.add_argument("--deadline-hours", type=int, default=24)

    # submit
    p = sub.add_parser("submit", help="Worker submits completed result")
    p.add_argument("worker_passphrase")
    p.add_argument("escrow_id")
    p.add_argument("result_content")
    p.add_argument("--sources", default="")
    p.add_argument("--recipient", default=None,
                   help="Payer address to receive the ESCROW:SUBMIT marker")

    # release
    p = sub.add_parser("release", help="Release payment to worker")
    p.add_argument("operator_passphrase")
    p.add_argument("escrow_id")

    # refund
    p = sub.add_parser("refund", help="Refund payment to payer")
    p.add_argument("operator_passphrase")
    p.add_argument("escrow_id")

    # status
    p = sub.add_parser("status", help="Check escrow status")
    p.add_argument("escrow_id")
    p.add_argument("--address", default=None)

    # receipt
    p = sub.add_parser("receipt", help="Print canonical escrow creation receipt")
    p.add_argument("escrow_id")
    p.add_argument("--address", required=True, help="Payer address to scan")

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    if args.cmd == "create":
        print(f"Creating escrow on {args.network}...")
        result, err = create_escrow(
            args.payer_passphrase, args.worker_address, args.amount,
            args.task_description, args.deadline_hours, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            receipt = _format_escrow_receipt(result, deadline_hours=args.deadline_hours)
            _store_last_receipt(receipt)
            tg_token, tg_chat_id = _load_tg_config()
            _send_telegram(tg_token, tg_chat_id,
                           f"🔐 *Escrow Created*\n"
                           f"ID: `{result['escrow_id']}`\n"
                           f"Record TX: `{result.get('record_tx') or result.get('create_tx')}`\n"
                           f"Fund TX: `{result.get('fund_tx')}`\n\n"
                           f"Task sent to worker ({float(args.amount):g} SIGNA, "
                           f"{float(args.deadline_hours):g}h deadline).")
            print()
            print(receipt, flush=True)

    elif args.cmd == "submit":
        sources = [s.strip() for s in args.sources.split(",") if s.strip()]
        print(f"Submitting result for escrow {args.escrow_id}...")
        result, err = submit_result(
            args.worker_passphrase, args.escrow_id,
            args.result_content, sources,
            recipient_address=args.recipient, network=args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Result submitted")
            print(f"  Escrow ID:   {result['escrow_id']}")
            print(f"  Result hash: {result['result_hash']}")
            print(f"  Proof TX:    {result['proof_tx']}")
            print(f"  Submit TX:   {result['submit_tx']}")

    elif args.cmd == "release":
        print(f"Releasing escrow {args.escrow_id}...")
        result, err = release_payment(
            args.operator_passphrase, args.escrow_id, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            receipt_lines = [
                f"Release submitted:",
                f"Escrow: {args.escrow_id}",
                f"Release TX: {result['tx_id']}",
            ]
            if result.get("at_address"):
                receipt_lines.append(f"AT: {result['at_address']}")
            receipt_lines.append(f"\nThe AT will pay the worker on the next block.")
            receipt = "\n".join(receipt_lines)

            print(f"\nSIGNAAI_FINAL_RESPONSE_BEGIN")
            print(receipt)
            print(f"SIGNAAI_FINAL_RESPONSE_END")

            tg_token, tg_chat_id = _load_tg_config()
            _send_telegram(tg_token, tg_chat_id,
                           f"🔐 *Release Submitted*\n"
                           f"Escrow: `{args.escrow_id}`\n"
                           f"Release TX: `{result['tx_id']}`\n"
                           f"AT: `{result.get('at_address', 'unknown')}`\n\n"
                           f"The AT will pay the worker on the next block.")

    elif args.cmd == "refund":
        print(f"Refunding escrow {args.escrow_id}...")
        result, err = refund_escrow(
            args.operator_passphrase, args.escrow_id, args.network
        )
        if err:
            print(f"Error: {err}")
        else:
            print(f"\n✓ Refund sent")
            print(f"  Payer:    {result['payer']}")
            print(f"  Amount:   {result['amount_signa']} SIGNA")
            print(f"  TX:       {result['tx_id']}")

    elif args.cmd == "status":
        result, err = get_escrow_status(args.escrow_id, args.address, args.network)
        if err:
            print(f"Error: {err}")
        else:
            state = result.get("state", "UNKNOWN")
            state_icons = {
                STATE_CREATED: "🟡", STATE_SUBMITTED: "🔵",
                STATE_RELEASED: "✅", STATE_REFUNDED: "↩️",
                "UNKNOWN": "❓"
            }
            print(f"\n{state_icons.get(state, '?')} Escrow {args.escrow_id}: {state}")
            for k, v in result.items():
                if k not in ("escrow_id", "state") and v:
                    print(f"  {k:<20} {v}")
    elif args.cmd == "receipt":
        result, err = get_escrow_status(args.escrow_id, args.address, args.network)
        if err:
            print(f"Error: {err}")
        elif result.get("state") == "UNKNOWN":
            print(f"Error: escrow {args.escrow_id} not found for {args.address}")
        elif not result.get("fund_tx"):
            print(f"Error: fund TX not found for escrow {args.escrow_id}")
        else:
            receipt = _format_escrow_receipt(result)
            _store_last_receipt(receipt)
            print(receipt)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/identity.py
content: #!/usr/bin/env python3
"""
Signum Agent Identity — register AI agents on-chain, look them up, query history.

Every agent gets:
  - A Signum address (their immutable identity)
  - A human-readable alias (e.g. "sig-agent-mk")
  - An on-chain metadata record (capabilities, version, owner)

Reputation is derived from transaction history — every completed task
is a permanent on-chain record. No central server required.

Usage:
  python3 identity.py register <passphrase> <agent_name> [--capabilities "trading,research"]
  python3 identity.py lookup <agent_name>
  python3 identity.py profile <address>
  python3 identity.py list
"""
import sys
import os
import json
import hashlib
sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, signa, ts, fmt_address, FEE_ALIAS, fee_message, ok
from wallet import get_my_address, get_transactions
from protocol import build_task_complete, parse_task_complete, TASK_COMPLETE_PREFIX


# ── Registry ─────────────────────────────────────────────────────────────────
# Aliases for AI agents follow the pattern: "sig-agent-<name>"
ALIAS_PREFIX = ""

# TASK_COMPLETE_PREFIX is now imported from protocol


def register_agent(passphrase, agent_name, capabilities=None, version="1.0",
                   description="", registry=None, network=None):
    """
    Register an AI agent identity on Signum.
    Uses the alias system to map a human-readable name to an address.
    Metadata stored in the alias URI as JSON.
    """
    api = get_api(network)

    # Get our address
    address, err = get_my_address(passphrase, network)
    if err:
        return None, f"Could not derive address: {err}"

    name_slug = agent_name.lower().replace(' ', '').replace('-', '').replace('_', '')
    name_hash = hashlib.sha256(agent_name.encode()).hexdigest()[:8]
    alias = f"{ALIAS_PREFIX}{name_slug}-{name_hash}"

    metadata = {
        "type": "ai-agent",
        "name": agent_name,
        "address": address,
        "version": version,
        "capabilities": capabilities or [],
        "description": description,
    }

    # Store as JSON in alias URI
    alias_uri = f"acct:{address};sig-agent:{json.dumps(metadata, separators=(',', ':'))}"

    result = api.post("setAlias",
                      secretPhrase=passphrase,
                      aliasName=alias,
                      aliasURI=alias_uri,
                      feeNQT=FEE_ALIAS)

    if not ok(result):
        return None, result.get("error", "Alias registration failed")

    reg_tx = result.get("transaction")

    # Broadcast to open registry so any agent can discover this one
    announce_msg = f"{AGENT_ANNOUNCE_PREFIX}{alias}:{address}"
    api.post("sendMessage",
             secretPhrase=passphrase,
             recipient=_registry_address(registry),
             message=announce_msg,
             messageIsText="true",
             feeNQT=fee_message(announce_msg))

    return {
        "alias":    alias,
        "address":  address,
        "tx_id":    reg_tx,
        "metadata": metadata,
    }, None


def lookup_agent(agent_name, network=None):
    """
    Look up a registered agent by name.
    Returns address and metadata if found.
    """
    api = get_api(network)
    name_slug = agent_name.lower().replace(' ', '').replace('-', '').replace('_', '')
    name_hash = hashlib.sha256(agent_name.encode()).hexdigest()[:8]
    alias = f"{ALIAS_PREFIX}{name_slug}-{name_hash}"

    result = api.get("getAlias", aliasName=alias)
    if not ok(result):
        return None, f"Agent '{agent_name}' not found"

    uri = result.get("aliasURI", "")
    metadata = {}

    # Parse metadata from URI
    if "sig-agent:" in uri:
        try:
            json_part = uri.split("sig-agent:")[1]
            metadata = json.loads(json_part)
        except:
            pass

    address = metadata.get("address") or (uri.split(";")[0].replace("acct:", "") if "acct:" in uri else "")

    return {
        "alias": alias,
        "address": address,
        "metadata": metadata,
        "account_id": result.get("account"),
    }, None


def verify_agent(agent_name, network=None):
    """
    Verify an agent's identity — confirm the alias is owned by the claimed address.
    Aliases are cryptographically tied to their owner's key pair in Signum,
    so a match proves the metadata was set by the address holder.
    """
    api = get_api(network)
    name_slug = agent_name.lower().replace(' ', '').replace('-', '').replace('_', '')
    name_hash = hashlib.sha256(agent_name.encode()).hexdigest()[:8]
    alias = f"{ALIAS_PREFIX}{name_slug}-{name_hash}"

    alias_result = api.get("getAlias", aliasName=alias)
    if not ok(alias_result):
        return None, f"Agent '{agent_name}' not found"

    # Alias owner account — the key pair that controls this alias
    alias_owner_id = alias_result.get("account", "")
    owner_info = api.get("getAccount", account=alias_owner_id) if alias_owner_id else {}
    alias_owner = owner_info.get("accountRS", alias_owner_id)

    uri = alias_result.get("aliasURI", "")
    metadata = {}
    if "sig-agent:" in uri:
        try:
            metadata = json.loads(uri.split("sig-agent:")[1])
        except Exception:
            pass

    claimed_address = metadata.get("address", "")
    verified = bool(claimed_address) and alias_owner == claimed_address

    return {
        "alias":           alias,
        "alias_owner":     alias_owner,
        "claimed_address": claimed_address,
        "verified":        verified,
        "name":            metadata.get("name", agent_name),
        "capabilities":    metadata.get("capabilities", []),
        "description":     metadata.get("description", ""),
    }, None


def get_agent_profile(address, network=None):
    """
    Get a full agent profile: registration + reputation from on-chain history.
    Reputation = completed tasks recorded as on-chain messages.
    """
    api = get_api(network)

    # Get account info
    account = api.get("getAccount", account=address)
    if not ok(account):
        return None, account.get("error")

    # Get all message transactions to derive reputation
    all_txs = api.get("getAccountTransactions",
                      account=address,
                      firstIndex=0,
                      lastIndex=99,
                      type=1)  # type 1 = messaging transactions

    tasks_completed = []
    total_rating = 0
    rating_count = 0

    for tx in (all_txs.get("transactions") or []):
        msg = tx.get("attachment", {}).get("message", "")
        if msg.startswith(TASK_COMPLETE_PREFIX):
            try:
                tc = parse_task_complete(msg)
                total_rating += tc.rating
                rating_count += 1
                tasks_completed.append({
                    "task_id":    tc.task_id,
                    "result_hash": tc.result_hash,
                    "rating":     tc.rating,
                    "timestamp":  ts(tx.get("timestamp")),
                    "tx_id":      tx.get("transaction"),
                })
            except Exception:
                pass

    avg_rating = (total_rating / rating_count) if rating_count > 0 else None

    return {
        "address": account.get("accountRS"),
        "balance": signa(account.get("balanceNQT", 0)),
        "tasks_completed": len(tasks_completed),
        "avg_rating": avg_rating,
        "reputation_score": _reputation_score(len(tasks_completed), avg_rating),
        "task_history": tasks_completed[:20],
    }, None


def get_escrow_reputation(address, network=None):
    """
    Compute trustless reputation from on-chain escrow history.
    Derived entirely from actual transactions — not self-reported.
    """
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex=0,
                     lastIndex=499)

    tasks_submitted = 0   # ESCROW:SUBMIT sent by this address (worker completions)
    escrows_created = 0   # ESCROW:CREATE sent by this address (payer activity)
    payments_received = 0 # ESCROW:RELEASE received by this address (paid as worker)

    for tx in (result.get("transactions") or []):
        msg     = tx.get("attachment", {}).get("message", "")
        sender  = tx.get("senderRS", "")
        recipient = tx.get("recipientRS", "")
        if msg.startswith("ESCROW:SUBMIT:") and sender == address:
            tasks_submitted += 1
        elif msg.startswith("ESCROW:CREATE:") and sender == address:
            escrows_created += 1
        elif msg.startswith("ESCROW:RELEASE:") and recipient == address:
            payments_received += 1

    completion_rate = (payments_received / tasks_submitted
                       if tasks_submitted > 0 else None)

    return {
        "tasks_submitted":    tasks_submitted,
        "escrows_created":    escrows_created,
        "payments_received":  payments_received,
        "completion_rate":    completion_rate,
    }


def record_task_completion(passphrase, task_id, result_hash, rating=5, network=None):
    """
    Record a completed task on-chain.
    This is the core reputation primitive — immutable, public, verifiable.

    task_id:     unique identifier for the task
    result_hash: SHA-256 hash of the delivered result (from verify.py)
    rating:      1-5 self-reported or third-party rating
    """
    api = get_api(network)
    address, err = get_my_address(passphrase, network)
    if err:
        return None, err

    try:
        message = build_task_complete(task_id, result_hash, rating)
    except Exception as e:
        return None, str(e)

    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=address,  # send to self — public record
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))

    if not ok(result):
        return None, result.get("error", "Failed to record task")

    return result.get("transaction"), None


# Open registry — any agent can join by sending a registration announcement.
# Set SIGNAAI_REGISTRY env var or pass registry= to register_agent()/list_agents()
# to use your own registry address. Defaults to the SignaAI mainnet registry.
DEFAULT_REGISTRY_ADDRESS = "S-PS4K-2KE2-8LEV-HD2YE"
AGENT_ANNOUNCE_PREFIX = "AGENT:v1:register:"


def _registry_address(override=None):
    import os
    return override or os.environ.get("SIGNAAI_REGISTRY") or DEFAULT_REGISTRY_ADDRESS


def list_agents(registry=None, network=None):
    """
    List all registered SignaAI agents from the open registry.
    Any agent that called register_agent() is discoverable here.
    """
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=_registry_address(registry),
                     firstIndex=0,
                     lastIndex=499)

    agents = {}
    for tx in (result.get("transactions") or []):
        msg    = tx.get("attachment", {}).get("message", "")
        sender = tx.get("senderRS", "")
        if not msg.startswith(AGENT_ANNOUNCE_PREFIX):
            continue
        rest = msg[len(AGENT_ANNOUNCE_PREFIX):]
        colon = rest.find(":")
        if colon < 0:
            continue
        alias, claimed_address = rest[:colon], rest[colon+1:]
        # Sender must match claimed address — prevents impersonation
        if sender != claimed_address:
            continue
        # Most recent announcement per address wins
        if claimed_address in agents:
            continue
        alias_result = api.get("getAlias", aliasName=alias)
        if not ok(alias_result):
            continue
        uri = alias_result.get("aliasURI", "")
        metadata = {}
        if "sig-agent:" in uri:
            try:
                metadata = json.loads(uri.split("sig-agent:")[1])
            except Exception:
                pass
        agents[claimed_address] = {
            "alias":        alias,
            "address":      claimed_address,
            "name":         metadata.get("name", alias),
            "capabilities": metadata.get("capabilities", []),
            "description":  metadata.get("description", ""),
            "version":      metadata.get("version", ""),
        }
    return list(agents.values())


def search_agents(capability=None, registry=None, network=None):
    """
    Search the agent marketplace by capability (case-insensitive substring match).
    Returns all registered agents if no capability filter given.
    """
    agents = list_agents(registry=registry, network=network)
    if not capability:
        return agents
    cap_lower = capability.lower()
    return [
        a for a in agents
        if any(cap_lower in c.lower() for c in a.get("capabilities", []))
    ]


def _reputation_score(tasks, avg_rating):
    """Simple reputation score: tasks completed × avg rating (max 500)."""
    if not tasks:
        return 0
    rating = avg_rating or 3.0
    return min(500, int(tasks * rating * 10))


# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
    import argparse
    parser = argparse.ArgumentParser(description="Signum Agent Identity")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "testnet"),
                        choices=["mainnet", "testnet"])
    sub = parser.add_subparsers(dest="cmd")

    p = sub.add_parser("register", help="Register a new agent identity")
    p.add_argument("passphrase")
    p.add_argument("agent_name")
    p.add_argument("--capabilities", default="", help="Comma-separated list")
    p.add_argument("--description", default="")
    p.add_argument("--version", default="1.0")

    p = sub.add_parser("lookup", help="Look up an agent by name")
    p.add_argument("agent_name")

    p = sub.add_parser("profile", help="Full agent profile + reputation")
    p.add_argument("address")

    p = sub.add_parser("record", help="Record a completed task (builds reputation)")
    p.add_argument("passphrase")
    p.add_argument("task_id")
    p.add_argument("result_hash")
    p.add_argument("--rating", type=int, default=5)

    sub.add_parser("list", help="List all registered agents")

    p = sub.add_parser("verify", help="Verify an agent's identity (alias owner check)")
    p.add_argument("agent_name")

    p = sub.add_parser("reputation", help="Show an agent's on-chain escrow reputation")
    p.add_argument("address")

    p = sub.add_parser("search", help="Search marketplace by capability")
    p.add_argument("capability", nargs="?", default=None,
                   help="Capability to search for (e.g. 'research', 'trading')")

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    if args.cmd == "register":
        caps = [c.strip() for c in args.capabilities.split(",") if c.strip()]
        print(f"Registering agent '{args.agent_name}' on {args.network}...")
        result, err = register_agent(args.passphrase, args.agent_name,
                                     capabilities=caps,
                                     description=args.description,
                                     version=args.version,
                                     network=args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"✓ Agent registered")
            print(f"  Alias:   {result['alias']}")
            print(f"  Address: {result['address']}")
            print(f"  TX:      {result['tx_id']}")

    elif args.cmd == "lookup":
        result, err = lookup_agent(args.agent_name, args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"Agent: {result['metadata'].get('name', args.agent_name)}")
            print(f"Address: {result['address']}")
            caps = result['metadata'].get('capabilities', [])
            if caps:
                print(f"Capabilities: {', '.join(caps)}")

    elif args.cmd == "profile":
        result, err = get_agent_profile(args.address, args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"Address:          {result['address']}")
            print(f"Balance:          {result['balance']:,.4f} SIGNA")
            print(f"Tasks Completed:  {result['tasks_completed']}")
            print(f"Avg Rating:       {result['avg_rating']:.1f}/5.0" if result['avg_rating'] else "Avg Rating:       —")
            print(f"Reputation Score: {result['reputation_score']}/500")
            if result['task_history']:
                print(f"\nRecent Tasks:")
                for t in result['task_history'][:5]:
                    print(f"  [{t['timestamp']}] Task {t['task_id']} — rating {t['rating']}/5")

    elif args.cmd == "record":
        tx_id, err = record_task_completion(args.passphrase, args.task_id,
                                            args.result_hash, args.rating,
                                            args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"✓ Task completion recorded on-chain")
            print(f"  TX: {tx_id}")

    elif args.cmd == "list":
        agents = list_agents(network=args.network)
        if not agents:
            print("No agents registered yet.")
        else:
            for a in agents:
                caps = ", ".join(a['capabilities']) if a['capabilities'] else "—"
                print(f"  {a['alias']:<30} {a['address']:<20} [{caps}]")

    elif args.cmd == "search":
        agents = search_agents(capability=args.capability, network=args.network)
        label = f"capability '{args.capability}'" if args.capability else "all capabilities"
        if not agents:
            print(f"No agents found for {label}.")
        else:
            print(f"Agent Marketplace — {len(agents)} agent(s) matching {label}:\n")
            for a in agents:
                caps = ", ".join(a['capabilities']) if a['capabilities'] else "—"
                desc = f"  {a['description']}" if a['description'] else ""
                print(f"  Name:         {a['name']}")
                print(f"  Address:      {a['address']}")
                print(f"  Capabilities: {caps}")
                if desc:
                    print(f"  Description:  {a['description']}")
                print()

    elif args.cmd == "verify":
        result, err = verify_agent(args.agent_name, args.network)
        if err:
            print(f"Error: {err}")
        else:
            status = "✓ VERIFIED" if result["verified"] else "✗ UNVERIFIED"
            print(f"{status} — {result['name']}")
            print(f"  Alias:        {result['alias']}")
            print(f"  Owner:        {result['alias_owner']}")
            print(f"  Claimed addr: {result['claimed_address']}")
            if result["capabilities"]:
                print(f"  Capabilities: {', '.join(result['capabilities'])}")
            if not result["verified"]:
                print(f"  WARNING: alias owner does not match claimed address")

    elif args.cmd == "reputation":
        result = get_escrow_reputation(args.address, args.network)
        print(f"On-chain reputation for {args.address}:")
        print(f"  Tasks submitted (worker):   {result['tasks_submitted']}")
        print(f"  Escrows created (payer):    {result['escrows_created']}")
        print(f"  Payments received:          {result['payments_received']}")
        if result["completion_rate"] is not None:
            print(f"  Completion rate:            {result['completion_rate']:.0%}")

    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/listener.py
content: #!/usr/bin/env python3
"""
SignaAI Task Listener — autonomous worker daemon.

Detection:
  Primary:  WebSocket (ws://localhost:8126/events) — real-time mempool detection
  Fallback: HTTP polling against public nodes (every 120s)

Execution (when signaai-worker.json is configured):
  On new ESCROW:ASSIGN →
    1. Call LLM (Anthropic Claude Haiku) to research the task
    2. Stamp result hash on Signum blockchain
    3. Wait for block confirmation (~4 min, polls every 30s)
    4. Self-verify stamp
    5. Submit result to escrow
    6. Notify payer via Telegram with TX IDs

Fallback (when no worker config):
  Trigger OpenClaw agent via /hooks/agent (requires manual approval)

Worker config: ~/.openclaw/signaai-worker.json
  {
    "passphrase": "your worker passphrase",
    "apiKey": ""    ← optional, falls back to ANTHROPIC_API_KEY env var
  }

Run continuously (launchd):
  python3 listener.py --address S-44S7-32XB-5DM5-5AL3K

Run once (test):
  python3 listener.py --address S-44S7-32XB-5DM5-5AL3K --once

Force polling mode:
  python3 listener.py --address S-44S7-32XB-5DM5-5AL3K --no-websocket
"""

import argparse
import base64
import fcntl
import hashlib
import json
import os
import queue
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.parse
from datetime import datetime

sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, ts, ok, NODES, USER_AGENT, fee_message
from protocol import parse_message, EscrowMessage

ESCROW_ASSIGN_PREFIX = "ESCROW:ASSIGN"
ESCROW_RESULT_PREFIX = "ESCROW:RESULT:"
STATE_FILE            = os.path.expanduser("~/.openclaw/workspace/signaai-listener-state.json")
TRIGGER_FILE          = os.path.expanduser("~/.openclaw/workspace/signaai-pending-tasks.json")
TRIGGER_LOCK          = TRIGGER_FILE + ".lock"
RESULT_INBOX_FILE     = os.path.expanduser("~/.openclaw/workspace/signaai-result-inbox.json")
RESULT_INBOX_LOCK     = RESULT_INBOX_FILE + ".lock"
PENDING_RELEASES_FILE    = os.path.expanduser("~/.openclaw/workspace/signaai-pending-releases.json")
PENDING_AUTO_RELEASE_FILE = os.path.expanduser("~/.openclaw/workspace/signaai-auto-release-queue.json")
DISPUTE_FILE             = os.path.expanduser("~/.openclaw/workspace/signaai-disputes.json")
LISTENER_LOCK_DIR        = os.path.expanduser("~/.openclaw/workspace")

REVIEW_MINUTES = int(os.environ.get("SIGNAAI_REVIEW_MINUTES", "10"))
AUTO_RELEASE   = os.environ.get("SIGNAAI_AUTO_RELEASE", "true").lower() == "true"

# Single-worker task queue — processes one escrow at a time, no parallel races
_task_queue = queue.Queue()

def _task_worker():
    """Background thread that drains the task queue one at a time."""
    while True:
        fn, args = _task_queue.get()
        try:
            fn(*args)
        except Exception as e:
            log(f"Task worker error: {e}")
        finally:
            _task_queue.task_done()

_worker_thread = threading.Thread(target=_task_worker, daemon=True)
_worker_thread.start()
OPENCLAW_CFG  = os.path.expanduser("~/.openclaw/openclaw.json")
WORKER_CFG    = os.path.expanduser("~/.openclaw/signaai-worker.json")

WS_HOST = "localhost"
WS_PORT = 8126
WS_PATH = "/events"

POLL_INTERVAL    = 120   # seconds between fallback polls
CONFIRM_POLL     = 30    # seconds between confirmation checks
CONFIRM_TIMEOUT  = 1200  # max seconds to wait for confirmation (20 min)
RESULT_CHUNK_BYTES = 600  # stays under Signum's practical message-size limit
_listener_lock_handle = None


# ── Logging ───────────────────────────────────────────────────────────────────

def now():
    return datetime.now().strftime("%H:%M:%S")

def log(msg):
    print(f"[{now()}] {msg}", flush=True)

def git_commit():
    """Return the checked-out git commit for startup diagnostics."""
    skill_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    try:
        return subprocess.check_output(
            ["git", "-C", skill_dir, "rev-parse", "--short", "HEAD"],
            text=True,
            stderr=subprocess.DEVNULL,
            timeout=3,
        ).strip()
    except Exception:
        return "unknown"

def acquire_listener_lock(network):
    """
    Hold an exclusive per-network lock for the listener process lifetime.

    This prevents a launchd daemon and a manual foreground listener from both
    processing the same escrow stream and writing contradictory log lines.
    """
    global _listener_lock_handle
    os.makedirs(LISTENER_LOCK_DIR, exist_ok=True)
    lock_path = os.path.join(LISTENER_LOCK_DIR, f"signaai-listener-{network}.lock")
    lock = open(lock_path, "a+")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        lock.seek(0)
        existing = lock.read().strip() or "unknown pid"
        print(f"Another SignaAI listener is already running for {network}: {existing}", flush=True)
        print("Stop the existing listener before starting a foreground copy.", flush=True)
        sys.exit(2)

    lock.seek(0)
    lock.truncate()
    lock.write(f"pid={os.getpid()} script={os.path.abspath(__file__)} commit={git_commit()}\n")
    lock.flush()
    _listener_lock_handle = lock
    return lock_path


# ── State / trigger file helpers ──────────────────────────────────────────────

def load_state():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE) as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return {"processed_txs": []}
    return {"processed_txs": []}

def save_state(state):
    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
    tmp = STATE_FILE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(state, f, indent=2)
    os.replace(tmp, STATE_FILE)

def load_pending():
    if os.path.exists(TRIGGER_FILE):
        try:
            with open(TRIGGER_FILE) as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return []
    return []

def save_pending(tasks):
    os.makedirs(os.path.dirname(TRIGGER_FILE), exist_ok=True)
    tmp = TRIGGER_FILE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(tasks, f, indent=2)
    os.replace(tmp, TRIGGER_FILE)

def with_pending_lock(mutator):
    """Run a pending-task mutation under a process-wide file lock."""
    os.makedirs(os.path.dirname(TRIGGER_FILE), exist_ok=True)
    with open(TRIGGER_LOCK, "a+") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        try:
            tasks = load_pending()
            result = mutator(tasks)
            save_pending(tasks)
            return result
        finally:
            fcntl.flock(lock, fcntl.LOCK_UN)

def claim_pending_task(task):
    """Atomically claim a task so duplicate listeners/events cannot queue it."""
    def mutate(tasks):
        for existing in tasks:
            same_tx = existing.get("tx_id") == task.get("tx_id")
            same_escrow = existing.get("escrow_id") == task.get("escrow_id")
            if same_tx or same_escrow:
                return False, existing.get("status", "unknown")
        tasks.append(task)
        return True, "claimed"
    return with_pending_lock(mutate)

def update_pending_task(escrow_id, **updates):
    """Atomically update the saved task record for an escrow."""
    def mutate(tasks):
        updated = False
        for task in tasks:
            if task.get("escrow_id") == escrow_id:
                task.update(updates)
                task["updated_at"] = datetime.now().isoformat()
                updated = True
        return updated
    return with_pending_lock(mutate)

def chain_has_worker_submission(escrow_id, address, network):
    """Return the worker's existing ESCROW:SUBMIT tx, if one is already on-chain."""
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex=0,
                     lastIndex=199)
    if not ok(result):
        log(f"Startup retry check failed for {escrow_id}: {result.get('error', 'unknown error')}")
        return None

    for tx in (result.get("transactions") or []):
        msg = tx.get("attachment", {}).get("message", "")
        try:
            parsed = parse_message(msg)
        except Exception:
            continue
        if not isinstance(parsed, EscrowMessage):
            continue
        if parsed.escrow_id == escrow_id and parsed.action == "SUBMIT":
            return tx.get("transaction")
    return None

def startup_retry_candidates(address, network):
    """
    Return stale pending/in_progress tasks that still need work.

    This path runs before the daemon starts watching new transactions, so it
    must do its own dedup and chain reconciliation instead of blindly re-queueing
    every saved pending row.
    """
    tasks = load_pending()
    seen = set()
    retries = []
    changed = False
    now_iso = datetime.now().isoformat()

    for task in tasks:
        status = task.get("status")
        if status not in ("pending", "in_progress"):
            continue

        escrow_id = task.get("escrow_id", "")
        task_desc = task.get("task_description", "")
        if not escrow_id or not task_desc:
            task["status"] = "skipped_invalid"
            task["skipped_at"] = now_iso
            changed = True
            continue

        if escrow_id in seen:
            task["status"] = "skipped_duplicate"
            task["skipped_at"] = now_iso
            changed = True
            continue
        seen.add(escrow_id)

        saved_submit_tx = str(task.get("submit_tx", "")).strip()
        if saved_submit_tx:
            task["status"] = "chain_submitted"
            task["updated_at"] = now_iso
            changed = True
            log(f"Startup retry skipped {escrow_id}: saved submit TX exists ({saved_submit_tx})")
            continue

        submit_tx = chain_has_worker_submission(escrow_id, address, network)
        if submit_tx:
            task["status"] = "chain_submitted"
            task["submit_tx"] = submit_tx
            task["updated_at"] = now_iso
            changed = True
            log(f"Startup retry skipped {escrow_id}: already submitted on-chain ({submit_tx})")
            continue

        retries.append(task)

    if changed:
        save_pending(tasks)
    return retries


# ── Result delivery helpers ──────────────────────────────────────────────────

def load_result_inbox():
    if os.path.exists(RESULT_INBOX_FILE):
        try:
            with open(RESULT_INBOX_FILE) as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return {"escrows": {}}
    return {"escrows": {}}

def save_result_inbox(inbox):
    os.makedirs(os.path.dirname(RESULT_INBOX_FILE), exist_ok=True)
    tmp = RESULT_INBOX_FILE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(inbox, f, indent=2)
    os.replace(tmp, RESULT_INBOX_FILE)

def with_result_inbox_lock(mutator):
    os.makedirs(os.path.dirname(RESULT_INBOX_FILE), exist_ok=True)
    with open(RESULT_INBOX_LOCK, "a+") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        try:
            inbox = load_result_inbox()
            result = mutator(inbox)
            save_result_inbox(inbox)
            return result
        finally:
            fcntl.flock(lock, fcntl.LOCK_UN)

def _result_record(inbox, escrow_id):
    escrows = inbox.setdefault("escrows", {})
    return escrows.setdefault(escrow_id, {
        "escrow_id": escrow_id,
        "chunks": {},
        "notified": False,
    })

def result_chunks(result_text):
    data = result_text.encode("utf-8")
    chunks = [data[i:i + RESULT_CHUNK_BYTES]
              for i in range(0, len(data), RESULT_CHUNK_BYTES)]
    return chunks or [b""]

def build_result_chunk_message(escrow_id, index, total, chunk):
    payload = base64.urlsafe_b64encode(chunk).decode("ascii").rstrip("=")
    return f"{ESCROW_RESULT_PREFIX}{escrow_id}:{index}:{total}:{payload}"

def parse_result_chunk_message(message):
    parts = message.split(":", 5)
    if len(parts) != 6 or parts[0] != "ESCROW" or parts[1] != "RESULT":
        raise ValueError("not an ESCROW:RESULT message")
    escrow_id = parts[2]
    index = int(parts[3])
    total = int(parts[4])
    payload = parts[5]
    if index < 1 or total < 1 or index > total:
        raise ValueError("invalid result chunk index")
    padding = "=" * (-len(payload) % 4)
    chunk = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
    return escrow_id, index, total, chunk

def publish_result_chunks(passphrase, recipient, escrow_id, result_text, network):
    """Deliver the result text to the payer account as chunked on-chain messages."""
    api = get_api(network)
    tx_ids = []
    chunks = result_chunks(result_text)
    total = len(chunks)

    for index, chunk in enumerate(chunks, start=1):
        message = build_result_chunk_message(escrow_id, index, total, chunk)
        sent = api.post("sendMessage",
                        secretPhrase=passphrase,
                        recipient=recipient,
                        message=message,
                        messageIsText="true",
                        feeNQT=fee_message(message))
        if not ok(sent):
            return tx_ids, sent.get("error", "Failed to send result chunk")
        tx_ids.append(sent.get("transaction"))
        time.sleep(1)

    return tx_ids, None

def update_result_submit(escrow_id, submit_tx, result_hash, proof_tx, sender):
    now_iso = datetime.now().isoformat()

    def mutate(inbox):
        record = _result_record(inbox, escrow_id)
        record.update({
            "submit_tx": str(submit_tx),
            "result_hash": result_hash,
            "proof_tx": str(proof_tx),
            "worker": sender,
            "updated_at": now_iso,
        })
        return record.copy()

    return with_result_inbox_lock(mutate)

def update_result_chunk(escrow_id, index, total, chunk, tx_id, sender):
    now_iso = datetime.now().isoformat()
    encoded = base64.urlsafe_b64encode(chunk).decode("ascii").rstrip("=")

    def mutate(inbox):
        record = _result_record(inbox, escrow_id)
        record["total_chunks"] = total
        record.setdefault("chunks", {})[str(index)] = encoded
        record.setdefault("chunk_txs", {})[str(index)] = str(tx_id)
        record["worker"] = sender
        record["updated_at"] = now_iso
        return record.copy()

    return with_result_inbox_lock(mutate)

def mark_result_notified(escrow_id):
    def mutate(inbox):
        record = _result_record(inbox, escrow_id)
        if record.get("notified"):
            return False
        record["notified"] = True
        record["notified_at"] = datetime.now().isoformat()
        return True

    return with_result_inbox_lock(mutate)

def assemble_result(record):
    total = int(record.get("total_chunks") or 0)
    chunks = record.get("chunks") or {}
    if total < 1 or len(chunks) < total:
        return None

    data = b""
    for index in range(1, total + 1):
        payload = chunks.get(str(index))
        if payload is None:
            return None
        padding = "=" * (-len(payload) % 4)
        data += base64.urlsafe_b64decode((payload + padding).encode("ascii"))
    return data.decode("utf-8")

def maybe_notify_payer_result(escrow_id, tg_token, tg_chat_id, network):
    record = load_result_inbox().get("escrows", {}).get(escrow_id, {})
    if record.get("notified") or not record.get("submit_tx"):
        return False

    result_text = assemble_result(record)
    if result_text is None:
        return False

    computed_hash = hashlib.sha256(result_text.encode("utf-8")).hexdigest()
    expected_hash = str(record.get("result_hash", ""))
    hash_ok = computed_hash == expected_hash

    proof_ok = False
    try:
        from verify import verify_proof
        proof_ok, _details = verify_proof(result_text, record.get("proof_tx"), network=network)
    except Exception as e:
        log(f"[{escrow_id}] Proof verification error before payer notify: {e}")

    status_line = "verified" if hash_ok and proof_ok else "verification failed"
    result_preview = result_text[:3000] + "..." if len(result_text) > 3000 else result_text
    delivered = send_telegram(tg_token, tg_chat_id, (
        f"✅ *SignaAI Task Complete*\n"
        f"Escrow: `{escrow_id}`\n\n"
        f"*Research Result:*\n{result_preview}\n\n"
        f"Stamp TX: `{record.get('proof_tx')}`\n"
        f"Submit TX: `{record.get('submit_tx')}`\n"
        f"Result hash: `{status_line}`\n\n"
        f"Payment review window: {REVIEW_MINUTES} minutes.\n"
        f"Auto-release scheduled unless disputed.\n\n"
        f"To block payment, reply:\n"
        f"Dispute escrow {escrow_id}"
    ), kind=f"payer_task_complete:{escrow_id}")

    if delivered:
        mark_result_notified(escrow_id)
        log(f"[{escrow_id}] Payer notified with delivered result")
        _queue_auto_release(escrow_id, record)
    return delivered

# ── Auto-release queue ───────────────────────────────────────────────────────

def _queue_auto_release(escrow_id, record):
    """Add escrow to auto-release queue after result is verified and payer notified."""
    if not AUTO_RELEASE:
        return
    release_after = time.time() + REVIEW_MINUTES * 60
    try:
        queue = {}
        if os.path.exists(PENDING_AUTO_RELEASE_FILE):
            with open(PENDING_AUTO_RELEASE_FILE) as f:
                queue = json.load(f)
        if escrow_id not in queue:
            queue[escrow_id] = {
                "release_after": release_after,
                "result_hash":   record.get("result_hash"),
                "proof_tx":      record.get("proof_tx"),
                "submit_tx":     record.get("submit_tx"),
                "status":        "pending_review",
            }
            with open(PENDING_AUTO_RELEASE_FILE, "w") as f:
                json.dump(queue, f, indent=2)
            log(f"[{escrow_id}] Auto-release queued in {REVIEW_MINUTES}m")
    except Exception as e:
        log(f"[{escrow_id}] Failed to queue auto-release: {e}")


def _is_disputed(escrow_id):
    """Check if payer has disputed this escrow."""
    try:
        if os.path.exists(DISPUTE_FILE):
            with open(DISPUTE_FILE) as f:
                disputes = json.load(f)
            return escrow_id in disputes
    except Exception:
        pass
    return False


def check_auto_releases(network, tg_token, tg_chat_id):
    """Fire pending auto-releases whose review window has passed."""
    if not AUTO_RELEASE or not os.path.exists(PENDING_AUTO_RELEASE_FILE):
        return
    try:
        with open(PENDING_AUTO_RELEASE_FILE) as f:
            queue = json.load(f)
    except Exception:
        return

    now = time.time()
    changed = False
    for escrow_id, entry in list(queue.items()):
        if entry.get("status") != "pending_review":
            continue
        if now < entry.get("release_after", float("inf")):
            continue
        if _is_disputed(escrow_id):
            log(f"[{escrow_id}] Auto-release skipped — disputed")
            queue[escrow_id]["status"] = "disputed"
            changed = True
            continue

        log(f"[{escrow_id}] Review window passed — auto-releasing...")
        try:
            sys.path.insert(0, os.path.dirname(__file__))
            from escrow import release_payment
            worker_cfg = {}
            if os.path.exists(WORKER_CFG):
                with open(WORKER_CFG) as f:
                    worker_cfg = json.load(f)
            passphrase = worker_cfg.get("passphrase", "")
            if not passphrase:
                log(f"[{escrow_id}] Auto-release failed — no passphrase in worker config")
                continue

            result, err = release_payment(passphrase, escrow_id, network)
            if err:
                log(f"[{escrow_id}] Auto-release error: {err}")
                queue[escrow_id]["status"] = "error"
            else:
                log(f"[{escrow_id}] Auto-released — TX: {result.get('tx_id')}")
                queue[escrow_id]["status"] = "released"
                at_line = f"\nAT: `{result['at_address']}`" if result.get('at_address') else ""
                send_telegram(tg_token, tg_chat_id,
                    f"🔐 *Release Submitted*\n"
                    f"Escrow: `{escrow_id}`\n"
                    f"Release TX: `{result.get('tx_id')}`{at_line}\n\n"
                    f"The AT will pay the worker on the next block.",
                    kind=f"auto_release:{escrow_id}"
                )
        except Exception as e:
            log(f"[{escrow_id}] Auto-release exception: {e}")
        changed = True

    if changed:
        with open(PENDING_AUTO_RELEASE_FILE, "w") as f:
            json.dump(queue, f, indent=2)


# ── AT payout watcher ────────────────────────────────────────────────────────

def _check_at_payout(entry, api):
    """Return payout TX info if the AT has paid out to the worker, else None."""
    at_address = entry.get("at_address", "")
    worker     = entry.get("worker", "")
    if not at_address or not worker:
        return None

    at_account = api.get("getAccount", account=at_address)
    if not ok(at_account):
        return None

    bal_nqt = int(at_account.get("balanceNQT", 0) or 0)
    if bal_nqt > 10_000_000:  # still holds > 0.1 SIGNA — not paid out yet
        return None

    # AT is drained — find payout TX in worker's recent incoming transactions
    at_numeric  = str(at_account.get("account", ""))
    worker_txs  = api.get("getAccountTransactions", account=worker,
                          firstIndex=0, lastIndex=49)
    if not ok(worker_txs):
        return None

    for tx in (worker_txs.get("transactions") or []):
        sender_id = str(tx.get("sender", ""))
        sender_rs = tx.get("senderRS", "")
        if sender_id == at_numeric or sender_rs == at_address:
            return {
                "tx_id":      tx.get("transaction"),
                "amount_nqt": int(tx.get("amountNQT", 0) or 0),
                "height":     tx.get("height"),
            }
    return None


def check_pending_releases(network, tg_token, tg_chat_id):
    """
    Check if any pending AT releases have paid out and notify the payer (MK).
    Runs on every poll cycle and every new block.
    """
    if not os.path.exists(PENDING_RELEASES_FILE):
        return

    try:
        with open(PENDING_RELEASES_FILE) as f:
            entries = json.load(f)
    except Exception:
        return

    if not entries:
        return

    api = get_api(network)
    remaining = []

    for entry in entries:
        escrow_id = entry.get("escrow_id", "?")
        payout = _check_at_payout(entry, api)

        if payout is None:
            remaining.append(entry)
            continue

        amount_signa = payout["amount_nqt"] / 100_000_000
        release_tx   = payout.get("tx_id", "unknown")
        worker       = entry.get("worker", "unknown")

        log(f"[{escrow_id}] AT payout confirmed — {amount_signa:.4f} SIGNA → {worker}")

        send_telegram(tg_token, tg_chat_id, (
            f"✅ *Escrow Released*\n"
            f"Escrow: `{escrow_id}`\n\n"
            f"Worker received: `{amount_signa:.4f} SIGNA`\n"
            f"Release TX: `{release_tx}`"
        ), kind=f"release_confirmed:{escrow_id}")

    try:
        tmp = PENDING_RELEASES_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(remaining, f, indent=2)
        os.replace(tmp, PENDING_RELEASES_FILE)
    except Exception:
        pass


# ── Config ────────────────────────────────────────────────────────────────────

def load_openclaw_config():
    """Read Telegram and hooks config from OpenClaw config."""
    try:
        with open(OPENCLAW_CFG) as f:
            cfg = json.load(f)
        tg = cfg.get("channels", {}).get("telegram", {})
        tg_token = tg.get("botToken", "") or None
        approvers = tg.get("execApprovals", {}).get("approvers", [])
        tg_chat_id = approvers[0] if approvers else None

        hooks = cfg.get("hooks", {})
        hook_enabled = hooks.get("enabled", False)
        hook_token = hooks.get("token", "") if hook_enabled else None
        hook_path = hooks.get("path", "/hooks")
        gw_port = cfg.get("gateway", {}).get("port", 18789)

        return tg_token, tg_chat_id, hook_token, hook_path, gw_port
    except Exception:
        return None, None, None, "/hooks", 18789

OPENCLAW_MODELS = os.path.expanduser("~/.openclaw/agents/main/agent/models.json")

ENV_VARS = {
    # provider prefix → env var name (fallback if models.json not present)
    "xai":       "XAI_API_KEY",
    "openai":    "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "groq":      "GROQ_API_KEY",
    "ollama":    None,  # no key needed
}

def load_openclaw_llm():
    """
    Read the active LLM provider, model, base URL, and API key from OpenClaw config.

    Primary source: ~/.openclaw/agents/main/agent/models.json
      — OpenClaw stores API keys here, readable by launchd daemons.

    Fallback: env vars (XAI_API_KEY etc.) for interactive shells.

    Returns (provider, model, base_url, api_key) or None if can't determine.
    """
    try:
        with open(OPENCLAW_CFG) as f:
            cfg = json.load(f)

        # e.g. "xai/grok-4" or "groq/llama-3.3-70b-versatile"
        primary = cfg.get("agents", {}).get("defaults", {}).get("model", {}).get("primary", "")
        if "/" not in primary:
            return None

        provider, model_id = primary.split("/", 1)

        # Read baseUrl and apiKey from models.json (where OpenClaw stores them)
        base_url = ""
        api_key  = ""
        try:
            with open(OPENCLAW_MODELS) as f:
                models_cfg = json.load(f)
            prov = models_cfg.get("providers", {}).get(provider, {})
            base_url = prov.get("baseUrl", "")
            [REDACTED_SECRET]"apiKey", "")
        except Exception:
            pass

        # Fallback: env var (interactive shells)
        if not [REDACTED_SECRET] = ENV_VARS.get(provider)
            [REDACTED_SECRET], "") if env_var else ""

        if not base_url:
            base_url, _ = LLM_PROVIDERS.get(provider, LLM_PROVIDERS["xai"])

        if not api_key and provider != "ollama":
            return None

        return provider, model_id, base_url, api_key
    except Exception:
        return None


def load_worker_config():
    """
    Load agent config from signaai-worker.json.

    Any agent can act as payer or worker — role is determined by context:
      - ESCROW:ASSIGN  → worker role (execute task, submit result)
      - Payers call escrow.py create directly (dedup prevents duplicates)

    LLM provider/model/key are read from OpenClaw's models.json automatically.
    Only passphrase is required in signaai-worker.json.
    """
    if not os.path.exists(WORKER_CFG):
        return None
    try:
        with open(WORKER_CFG) as f:
            cfg = json.load(f)
        passphrase = str(cfg.get("passphrase", "")).strip()
        if not passphrase:
            return None

        llm = load_openclaw_llm()
        if not llm:
            log("Could not load LLM config from OpenClaw — is a provider configured?")
            return None

        provider, model_id, base_url, api_key = llm
        cfg["passphrase"] = passphrase
        cfg["provider"]   = provider
        cfg["model"]      = model_id
        cfg["baseUrl"]    = base_url
        cfg["apiKey"]     = api_key
        return cfg
    except Exception as e:
        log(f"Agent config error: {e}")
        return None


# ── Telegram ──────────────────────────────────────────────────────────────────

def send_telegram(token, chat_id, message, kind="message"):
    if not token or not chat_id:
        return False
    def post(parse_mode):
        url = f"https://api.telegram.org/bot{token}/sendMessage"
        payload = {
            "chat_id": chat_id,
            "text": message
        }
        if parse_mode:
            payload["parse_mode"] = parse_mode
        data = urllib.parse.urlencode(payload).encode()
        response = urllib.request.urlopen(
            urllib.request.Request(url, data=data), timeout=10
        )
        return json.loads(response.read().decode("utf-8"))

    try:
        payload = post("Markdown")
        message_id = payload.get("result", {}).get("message_id", "?")
        log(f"Telegram sent ({kind}) message_id={message_id}")
        return True
    except Exception as e:
        log(f"Telegram Markdown send failed ({kind}): {e}; retrying plain text")
        try:
            payload = post(None)
            message_id = payload.get("result", {}).get("message_id", "?")
            log(f"Telegram sent ({kind}) message_id={message_id} plain_text=true")
            return True
        except Exception as plain_error:
            log(f"Telegram send failed ({kind}): {plain_error}")
            return False


# ── Hooks fallback (OpenClaw agent trigger) ───────────────────────────────────

def trigger_agent(hook_token, hook_path, gw_port, escrow_id, sender,
                  worker_address, task_hash, task_description=""):
    """POST to OpenClaw /hooks/agent — fallback when no worker config."""
    if not hook_token:
        return
    try:
        skill_dir = os.path.expanduser("~/.openclaw/workspace/skills/signaai/scripts")
        task_hint = f'Task: "{task_description[:200]}"' if task_description else f"task_hash: {task_hash[:16]}..."
        message = (
            f"New SignaAI task assigned to you.\n\n"
            f"Escrow ID: {escrow_id}\n"
            f"Payer: {sender}\n"
            f"Your wallet: {worker_address}\n"
            f"{task_hint}\n\n"
            f"Run these steps IN ORDER. Use ONLY --network mainnet flag. "
            f"NEVER use SIGNUM_NETWORK= prefix. NEVER output placeholder values.\n\n"
            f"1. python3 {skill_dir}/escrow.py --network mainnet status {escrow_id} --address {worker_address}\n"
            f"2. Research the task.\n"
            f"3. python3 {skill_dir}/verify.py --network mainnet stamp \"<passphrase>\" \"<result>\"\n"
            f"4. Wait 4 minutes.\n"
            f"5. python3 {skill_dir}/verify.py --network mainnet verify \"<result>\" <stamp_tx>\n"
            f"6. python3 {skill_dir}/escrow.py --network mainnet submit \"<passphrase>\" {escrow_id} \"<result>\"\n\n"
            f"Show actual TX IDs from script output only."
        )
        url = f"http://127.0.0.1:{gw_port}{hook_path}/agent"
        data = json.dumps({
            "message": message,
            "name": "SignaAI Worker",
            "agentId": "main",
            "timeoutSeconds": 900,
        }).encode()
        req = urllib.request.Request(url, data=data, headers={
            "Authorization": f"Bearer {hook_token}",
            "Content-Type": "application/json",
        })
        urllib.request.urlopen(req, timeout=10)
        log(f"Agent triggered via hooks API for escrow {escrow_id}")
    except Exception as e:
        log(f"Hooks trigger failed: {e}")


# ── Autonomous execution ──────────────────────────────────────────────────────

LLM_PROVIDERS = {
    # provider  → (base_url, default_model)
    "xai":       ("https://api.x.ai/v1",                  "grok-3-mini"),
    "openai":    ("https://api.openai.com/v1",             "gpt-4o-mini"),
    "groq":      ("https://api.groq.com/openai/v1",        "llama-3.3-70b-versatile"),
    "anthropic": (None,                                    "claude-haiku-4-5-20251001"),  # separate handler
    "ollama":    ("http://localhost:11434/v1",             "llama3.1:8b"),               # no key needed
}

def call_llm(task_description, api_key, provider="xai", model=None, base_url=None):
    """
    Call LLM to research a task. Returns result text.

    provider/model/base_url are read from openclaw.json via load_openclaw_llm().
    Falls back to LLM_PROVIDERS defaults if base_url not supplied.

    Supported providers: xai, openai, groq, anthropic, ollama
    All except anthropic use the OpenAI-compatible /chat/completions endpoint.
    """
    prompt = (
        "You are a research assistant. Complete this task thoroughly and accurately. "
        "Cite any specific facts, figures, or sources you use.\n\n"
        f"Task: {task_description}"
    )

    if provider == "anthropic":
        url = "https://api.anthropic.com/v1/messages"
        data = json.dumps({
            "model": model or "claude-haiku-4-5-20251001",
            "max_tokens": 1500,
            "messages": [{"role": "user", "content": prompt}]
        }).encode()
        req = urllib.request.Request(url, data=data, headers={
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
            "content-type": "application/json",
        })
        resp = json.loads(urllib.request.urlopen(req, timeout=120).read())
        return resp["content"][0]["text"]

    # All other providers: OpenAI-compatible
    # Use base_url from openclaw.json if available, otherwise fall back to defaults
    if not base_url:
        base_url, _ = LLM_PROVIDERS.get(provider, LLM_PROVIDERS["xai"])
    if not model:
        _, model = LLM_PROVIDERS.get(provider, LLM_PROVIDERS["xai"])

    url = f"{base_url}/chat/completions"
    headers = {"content-type": "application/json"}
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"
    data = json.dumps({
        "model": model,
        "max_tokens": 1500,
        "messages": [{"role": "user", "content": prompt}]
    }).encode()
    req = urllib.request.Request(url, data=data, headers=headers)
    resp = json.loads(urllib.request.urlopen(req, timeout=120).read())
    return resp["choices"][0]["message"]["content"]


def get_transaction_any_node(tx_id, network):
    """
    Fetch a transaction from the active API node, then fan out to peers if it is
    still propagating. Public Signum nodes can briefly disagree on fresh TX IDs.
    """
    api = get_api(network)
    result = api.get("getTransaction", transaction=str(tx_id))
    active_node = getattr(api, "active_node", "?")
    if ok(result) or "Unknown transaction" not in str(result.get("error", "")):
        return result, active_node

    params = urllib.parse.urlencode({
        "requestType": "getTransaction",
        "transaction": str(tx_id),
    })
    for node in NODES.get(network, []):
        if node == active_node:
            continue
        try:
            req = urllib.request.Request(
                f"{node}/api?{params}",
                headers={"User-Agent": USER_AGENT},
            )
            with urllib.request.urlopen(req, timeout=15) as resp:
                peer_result = json.loads(resp.read())
            if "errorDescription" in peer_result:
                peer_result = {
                    "error": peer_result["errorDescription"],
                    "errorCode": peer_result.get("errorCode"),
                }
            if ok(peer_result):
                return peer_result, node
        except Exception:
            continue
    return result, active_node


def wait_for_confirmation(tx_id, network):
    """Poll until TX has at least 1 confirmation. Returns True/False."""
    deadline = time.time() + CONFIRM_TIMEOUT
    attempt = 0
    while time.time() < deadline:
        result, node = get_transaction_any_node(tx_id, network)
        if ok(result):
            confirmations = int(result.get("confirmations", 0) or 0)
            height = int(result.get("height", 2147483647) or 2147483647)
            if confirmations >= 1:
                log(f"  TX {tx_id} confirmed on {network} via {node} "
                    f"(height={height}, confirmations={confirmations})")
                return True
            if height == 2147483647:
                status = "seen in mempool"
            else:
                status = f"mined at height {height}, waiting for confirmation"
        else:
            status = result.get("error", "unknown error")
        elapsed = attempt * CONFIRM_POLL
        log(f"  Waiting for block confirmation... ({elapsed}s elapsed, "
            f"network={network}, node={node}, status={status})")
        time.sleep(CONFIRM_POLL)
        attempt += 1
    return False


def execute_task_autonomously(escrow_id, assign_tx_id, task_description, sender, worker_address,
                               network, worker_cfg, tg_token, tg_chat_id):
    """
    Full autonomous task execution in a background thread.
    The LLM does the research. Python handles all blockchain operations.
    No exec calls, no approval prompts, no hallucinated TX IDs.
    """
    from verify import hash_content, publish_proof, verify_proof
    from escrow import submit_proof

    passphrase = worker_cfg["passphrase"]
    [REDACTED_SECRET]"apiKey"]
    provider   = worker_cfg.get("provider", "xai")
    model      = worker_cfg.get("model")
    base_url   = worker_cfg.get("baseUrl")

    log(f"[{escrow_id}] Autonomous execution starting")
    log(f"[{escrow_id}] Task: {task_description[:100]}...")
    update_pending_task(escrow_id, status="in_progress", started_at=datetime.now().isoformat())

    def fail(reason):
        log(f"[{escrow_id}] FAILED: {reason}")
        update_pending_task(escrow_id, status="failed", error=reason,
                            failed_at=datetime.now().isoformat())
        send_telegram(tg_token, tg_chat_id,
                      f"❌ *SignaAI Task Failed*\nEscrow: `{escrow_id}`\n{reason}",
                      kind=f"task_failed:{escrow_id}")

    # Step 0: Confirm the assignment TX is on-chain before doing any work
    # Prevents executing tasks from dropped or orphaned transactions
    if assign_tx_id:
        log(f"[{escrow_id}] Waiting for assignment TX to confirm...")
        if not wait_for_confirmation(assign_tx_id, network):
            fail(f"Assignment TX {assign_tx_id} not confirmed — task may have been dropped")
            return
        log(f"[{escrow_id}] Assignment confirmed ✓")

    # Step 1: Research via LLM
    if not api_key and provider != "ollama":
        env_var = ENV_VARS.get(provider, "API key")
        fail(f"No API key — set {env_var} environment variable")
        return
    try:
        log(f"[{escrow_id}] Calling LLM ({provider}/{model}) for research...")
        result = call_llm(task_description, api_key, provider, model=model, base_url=base_url)
        log(f"[{escrow_id}] Research complete ({len(result)} chars)")
    except Exception as e:
        fail(f"LLM call failed: {e}")
        return

    # Step 2: Stamp result on-chain
    try:
        log(f"[{escrow_id}] Stamping result on Signum...")
        hashes = hash_content(result)
        proof, err = publish_proof(passphrase, hashes["content_hash"],
                                   hashes["sources_hash"],
                                   label=f"escrow-{escrow_id}",
                                   network=network)
        if err:
            raise Exception(err)
        stamp_tx   = proof["tx_id"]
        result_hash = hashes["content_hash"]
        log(f"[{escrow_id}] Stamp TX: {stamp_tx}")
    except Exception as e:
        fail(f"Stamp failed: {e}")
        return

    # Step 3: Wait for block confirmation
    log(f"[{escrow_id}] Waiting for stamp to confirm (~4 min)...")
    if not wait_for_confirmation(stamp_tx, network):
        fail(f"Stamp TX not confirmed after {CONFIRM_TIMEOUT}s — TX: {stamp_tx}")
        return
    log(f"[{escrow_id}] Stamp confirmed ✓")

    # Step 4: Self-verify
    try:
        log(f"[{escrow_id}] Self-verifying stamp...")
        verified, details = verify_proof(result, stamp_tx, network=network)
        if not verified:
            raise Exception(f"Hash mismatch: {details.get('onchain_content_hash', '?')} vs {details.get('computed_content_hash', '?')}")
        log(f"[{escrow_id}] Self-verified ✓")
    except Exception as e:
        fail(f"Self-verify failed: {e}")
        return

    # Step 5: Deliver result text to the payer's account as chunked messages.
    # The payer-side listener reassembles these chunks and notifies MK locally.
    result_chunk_txs = []
    try:
        log(f"[{escrow_id}] Delivering result chunks to payer {sender}...")
        result_chunk_txs, err = publish_result_chunks(passphrase, sender, escrow_id,
                                                      result, network)
        if err:
            raise Exception(err)
        log(f"[{escrow_id}] Result chunks sent: {', '.join(map(str, result_chunk_txs))}")
    except Exception as e:
        update_pending_task(escrow_id, status="result_delivery_failed",
                            stamp_tx=stamp_tx,
                            result_hash=result_hash, error=str(e),
                            failed_at=datetime.now().isoformat())
        send_telegram(tg_token, tg_chat_id,
                      f"⚠️ *SignaAI Result Delivery Failed*\n"
                      f"Escrow: `{escrow_id}`\n"
                      f"Stamp TX: `{stamp_tx}`\n"
                      f"{e}",
                      kind=f"result_delivery_failed:{escrow_id}")
        return

    # Step 6: Submit to escrow. Send the marker to the payer so their listener
    # can detect completion without relying on the worker's local Telegram.
    try:
        log(f"[{escrow_id}] Submitting to escrow...")
        submission, err = submit_proof(passphrase, escrow_id, result_hash, stamp_tx,
                                       recipient_address=sender, network=network)
        if err:
            raise Exception(err)
        submit_tx = submission["submit_tx"]
        log(f"[{escrow_id}] Submit TX: {submit_tx}")
    except Exception as e:
        fail(f"Submit failed after result delivery: {e}")
        return

    # Step 7: Local worker receipt only. The full result goes to the payer-side
    # listener so the agent that created the escrow gets the completion message.
    send_telegram(tg_token, tg_chat_id, (
        f"✅ *SignaAI Task Submitted*\n"
        f"Escrow: `{escrow_id}`\n\n"
        f"Result delivered to payer: `{sender}`\n"
        f"Stamp TX: `{stamp_tx}`\n"
        f"Submit TX: `{submit_tx}`"
    ), kind=f"task_submitted:{escrow_id}")

    update_pending_task(escrow_id, status="complete", stamp_tx=stamp_tx,
                        submit_tx=submit_tx, result_hash=result_hash,
                        result_chunk_txs=result_chunk_txs,
                        completed_at=datetime.now().isoformat())

    log(f"[{escrow_id}] Complete ✓  stamp={stamp_tx}  submit={submit_tx}")


# ── Transaction handler ───────────────────────────────────────────────────────

def handle_transaction(tx, address, network, state, tg_token, tg_chat_id,
                       hook_token=None, hook_path="/hooks", gw_port=18789,
                       worker_cfg=None):
    """
    Handle SignaAI messages destined for our address.

    - ESCROW:ASSIGN: worker-side task execution
    - ESCROW:SUBMIT / ESCROW:RESULT: payer-side completion inbox

    Returns True if a relevant message was recorded.
    """
    tx_id = str(tx.get("transaction", tx.get("id", "")))
    if not tx_id:
        return False

    processed = set(state.get("processed_txs", []))
    if tx_id in processed:
        return False

    processed.add(tx_id)
    state["processed_txs"] = list(processed)[-500:]

    recipient = tx.get("recipientRS", tx.get("recipient", ""))
    if address not in recipient:
        return False

    msg = tx.get("attachment", {}).get("message", "")
    sender = tx.get("senderRS", tx.get("sender", "unknown"))

    if msg.startswith(ESCROW_RESULT_PREFIX):
        try:
            escrow_id, index, total, chunk = parse_result_chunk_message(msg)
        except Exception as e:
            log(f"Malformed result chunk in TX {tx_id}: {e}")
            return False
        update_result_chunk(escrow_id, index, total, chunk, tx_id, sender)
        log(f"Result chunk {index}/{total} received for escrow {escrow_id} (TX {tx_id})")
        maybe_notify_payer_result(escrow_id, tg_token, tg_chat_id, network)
        return True

    try:
        parsed = parse_message(msg)
    except Exception:
        return False
    if not isinstance(parsed, EscrowMessage):
        return False

    if parsed.action == "SUBMIT":
        escrow_id = parsed.escrow_id
        update_result_submit(escrow_id, tx_id, parsed.result_hash,
                             parsed.proof_tx, sender)
        log(f"Escrow submission received — escrow {escrow_id} from {sender} "
            f"(proof {parsed.proof_tx}, TX {tx_id})")
        maybe_notify_payer_result(escrow_id, tg_token, tg_chat_id, network)
        return True

    if parsed.action == "CREATE" and parsed.worker == address and parsed.task_description:
        # Worker received a CREATE with task description — treat as task assignment
        escrow_id        = parsed.escrow_id
        task_hash        = parsed.task_hash
        task_description = parsed.task_description
        log(f"CREATE task — escrow {escrow_id} from {sender} (TX {tx_id})")
    elif parsed.action != "ASSIGN":
        return False
    else:
        escrow_id        = parsed.escrow_id
        task_hash        = parsed.task_hash
        task_description = parsed.task_description

    task = {
        "escrow_id":        escrow_id,
        "tx_id":            tx_id,
        "sender":           sender,
        "task_description": task_description,
        "timestamp":        ts(tx.get("timestamp", 0)),
        "raw_message":      msg,
        "detected_at":      datetime.now().isoformat(),
        "status":           "pending",
    }

    claimed, status = claim_pending_task(task)
    if not claimed:
        log(f"Duplicate task ignored — escrow {escrow_id} already {status} (TX {tx_id})")
        return False

    log(f"New task — escrow {escrow_id} from {sender} (TX {tx_id})")

    new_task_notified = send_telegram(tg_token, tg_chat_id, (
        f"*SignaAI: New Task*\n"
        f"Escrow: `{escrow_id}`\n"
        f"From: `{sender}`\n\n"
        f"{'Processing autonomously...' if worker_cfg and task_description else 'Triggering agent...'}"
    ), kind=f"new_task:{escrow_id}")
    if new_task_notified:
        update_pending_task(escrow_id, notified_at=datetime.now().isoformat())
        time.sleep(2)

    if worker_cfg and task_description:
        # Queue task for sequential processing — one at a time, no parallel races
        notify_token   = tg_token
        notify_chat_id = tg_chat_id
        queue_depth = _task_queue.qsize()
        if queue_depth > 0:
            log(f"Task queued (position {queue_depth + 1}) — escrow {escrow_id}")
        _task_queue.put((execute_task_autonomously, (
            escrow_id, tx_id, task_description, sender, address,
            network, worker_cfg, notify_token, notify_chat_id
        )))
    else:
        # Fallback: trigger OpenClaw agent via hooks API
        if not task_description:
            log(f"  No task description in ASSIGN message — falling back to agent trigger")
        trigger_agent(hook_token, hook_path, gw_port, escrow_id, sender,
                      address, task_hash, task_description)

    return True


def fetch_and_check(tx_id, address, network, state, tg_token, tg_chat_id,
                    hook_token=None, hook_path="/hooks", gw_port=18789,
                    worker_cfg=None):
    """Fetch a full transaction by ID then run handle_transaction on it."""
    api = get_api(network)
    result = api.get("getTransaction", transaction=str(tx_id))
    if not ok(result):
        log(f"Could not fetch TX {tx_id}: {result.get('error', 'unknown error')}")
        return False
    return handle_transaction(result, address, network, state, tg_token, tg_chat_id,
                              hook_token, hook_path, gw_port, worker_cfg)


# ── Minimal WebSocket client (stdlib only) ────────────────────────────────────

def _recv_exact(sock, n):
    buf = b""
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("Connection closed")
        buf += chunk
    return buf

def _ws_recv_frame(sock):
    header = _recv_exact(sock, 2)
    opcode = header[0] & 0x0F
    masked = (header[1] & 0x80) != 0
    length = header[1] & 0x7F

    if length == 126:
        length = struct.unpack(">H", _recv_exact(sock, 2))[0]
    elif length == 127:
        length = struct.unpack(">Q", _recv_exact(sock, 8))[0]

    mask_key = _recv_exact(sock, 4) if masked else b""
    payload  = _recv_exact(sock, length)

    if masked:
        payload = bytes(payload[i] ^ mask_key[i % 4] for i in range(len(payload)))

    return opcode, payload

def _ws_send_pong(sock, payload=b""):
    mask_key = os.urandom(4)
    masked = bytes(payload[i] ^ mask_key[i % 4] for i in range(len(payload)))
    sock.sendall(bytes([0x8A, 0x80 | len(payload)]) + mask_key + masked)

def ws_connect(host, port, path):
    key = base64.b64encode(os.urandom(16)).decode()
    sock = socket.create_connection((host, port), timeout=10)
    sock.settimeout(90)
    sock.sendall((
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Upgrade: websocket\r\n"
        f"Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        f"Sec-WebSocket-Version: 13\r\n\r\n"
    ).encode())
    resp = b""
    while b"\r\n\r\n" not in resp:
        resp += sock.recv(1)
    if b"101" not in resp:
        sock.close()
        raise ConnectionError(f"WebSocket upgrade failed: {resp[:80]}")
    return sock


# ── WebSocket event loop ──────────────────────────────────────────────────────

def run_websocket(address, network, state, tg_token, tg_chat_id,
                  hook_token=None, hook_path="/hooks", gw_port=18789,
                  worker_cfg=None):
    """
    Connect to local node WebSocket and process events.
    Returns True  → reconnect (transient error).
    Returns False → fall back to polling (node not available).
    """
    try:
        sock = ws_connect(WS_HOST, WS_PORT, WS_PATH)
    except (ConnectionRefusedError, OSError):
        return False

    log(f"WebSocket connected — ws://{WS_HOST}:{WS_PORT}{WS_PATH}")

    try:
        while True:
            opcode, payload = _ws_recv_frame(sock)

            if opcode == 0x9:
                _ws_send_pong(sock, payload)
                continue
            if opcode == 0x8:
                log("WebSocket closed by node")
                return True
            if opcode != 0x1:
                continue

            try:
                event = json.loads(payload.decode("utf-8"))
            except (json.JSONDecodeError, UnicodeDecodeError):
                continue

            etype    = event.get("e", "")
            epayload = event.get("p", {})

            if etype == "CONNECTED":
                local  = epayload.get("localHeight", "?")
                total  = epayload.get("globalHeight", "?")
                pct    = f"{local/total*100:.1f}%" if isinstance(local, int) and isinstance(total, int) and total else "?"
                syncing = epayload.get("isSyncing", False)
                log(f"Node: {epayload.get('networkName','?')} height={local}/{total} ({pct}) "
                    f"{'— syncing' if syncing else '— synced'}")

            elif etype == "HEARTBEAT":
                pass

            elif etype == "BLOCK_PUSHED":
                local   = epayload.get("localHeight", 0)
                total   = epayload.get("globalHeight", 0)
                syncing = local < total
                if not syncing:
                    log(f"Block {local} pushed")
                    check_pending_releases(network, tg_token, tg_chat_id)
                    check_auto_releases(network, tg_token, tg_chat_id)
                elif local % 10000 == 0:
                    pct = f"{local/total*100:.1f}%" if total else "?"
                    log(f"Syncing... {local}/{total} ({pct})")

            elif etype == "PENDING_TRANSACTIONS_ADDED":
                tx_ids = epayload.get("transactionIds", [])
                if not tx_ids:
                    continue
                log(f"{len(tx_ids)} pending TX(s) — checking...")
                found = sum(
                    fetch_and_check(tid, address, network, state, tg_token, tg_chat_id,
                                    hook_token, hook_path, gw_port, worker_cfg)
                    for tid in tx_ids
                )
                save_state(state)
                if not found:
                    log("No new tasks in batch")

    except (ConnectionError, socket.timeout, OSError) as e:
        log(f"WebSocket error: {e}")
        return True
    finally:
        try:
            sock.close()
        except Exception:
            pass


# ── Polling fallback ──────────────────────────────────────────────────────────

def poll_once(address, network, state, tg_token, tg_chat_id,
              hook_token=None, hook_path="/hooks", gw_port=18789,
              worker_cfg=None):
    api = get_api(network)
    result = api.get("getAccountTransactions",
                     account=address,
                     firstIndex="0",
                     lastIndex="49")
    if not ok(result):
        log(f"API error: {result.get('error')}")
        return

    found = sum(
        handle_transaction(tx, address, network, state, tg_token, tg_chat_id,
                           hook_token, hook_path, gw_port, worker_cfg)
        for tx in (result.get("transactions") or [])
    )
    save_state(state)
    if not found:
        log("No new tasks")
    check_pending_releases(network, tg_token, tg_chat_id)
    check_auto_releases(network, tg_token, tg_chat_id)


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="SignaAI Autonomous Worker Daemon")
    parser.add_argument("--address",       default=None,   help="Wallet address to monitor (derived from signaai-worker.json if omitted)")
    parser.add_argument("--network",       default="mainnet", choices=["mainnet", "testnet"])
    parser.add_argument("--poll-interval", type=int, default=POLL_INTERVAL)
    parser.add_argument("--once",          action="store_true", help="Poll once and exit")
    parser.add_argument("--no-websocket",  action="store_true", help="Force polling mode")
    args = parser.parse_args()

    lock_path = acquire_listener_lock(args.network)

    tg_token, tg_chat_id, hook_token, hook_path, gw_port = load_openclaw_config()
    worker_cfg = load_worker_config()

    # Derive address from worker config if not supplied on the command line
    if not args.address:
        if not worker_cfg:
            parser.error("--address is required when no signaai-worker.json is present")
        from wallet import get_my_address
        addr, err = get_my_address(worker_cfg["passphrase"], args.network)
        if err:
            parser.error(f"Could not derive address from worker config: {err}")
        args.address = addr

    print(f"SignaAI Listener starting", flush=True)
    print(f"  PID:         {os.getpid()}", flush=True)
    print(f"  Script:      {os.path.abspath(__file__)}", flush=True)
    print(f"  Commit:      {git_commit()}", flush=True)
    print(f"  Address:     {args.address}", flush=True)
    print(f"  Network:     {args.network}", flush=True)
    print(f"  Lock:        {lock_path}", flush=True)
    print(f"  Tasks:       {TRIGGER_FILE}", flush=True)
    print(f"  Confirm wait:{CONFIRM_TIMEOUT}s ({CONFIRM_POLL}s poll)", flush=True)
    print(f"  Telegram:    {'enabled' if tg_token else 'disabled'}", flush=True)
    if worker_cfg:
        print(f"  Mode:        AUTONOMOUS — payer + worker (any agent can be either)", flush=True)
        provider = worker_cfg.get("provider", "?")
        model    = worker_cfg.get("model", "?")
        has_key  = bool(worker_cfg.get("apiKey")) or provider == "ollama"
        env_hint = ENV_VARS.get(provider, "API key")
        print(f"  LLM:         {provider}/{model} ({'ready' if has_key else f'NO API KEY — set {env_hint}'})", flush=True)
    else:
        print(f"  Mode:        agent trigger (configure {WORKER_CFG} for autonomous)", flush=True)
    print(flush=True)

    state = load_state()

    # On startup: re-queue any tasks that were pending when the daemon last stopped
    if worker_cfg:
        retries = startup_retry_candidates(args.address, args.network)
        if retries:
            log(f"Retrying {len(retries)} pending task(s) from previous session...")
            for task in retries:
                escrow_id = task.get("escrow_id", "")
                task_desc = task.get("task_description", "")
                sender    = task.get("sender", "")
                if not escrow_id or not task_desc:
                    continue
                log(f"  Re-queuing escrow {escrow_id}")
                _task_queue.put((execute_task_autonomously, (
                    escrow_id, task.get("tx_id", ""), task_desc, sender, args.address,
                    args.network, worker_cfg, tg_token, tg_chat_id
                )))

    if args.once:
        poll_once(args.address, args.network, state, tg_token, tg_chat_id,
                  hook_token, hook_path, gw_port, worker_cfg)
        return

    if args.no_websocket:
        print(f"  Connection:  polling every {args.poll_interval}s", flush=True)
        while True:
            state = load_state()
            poll_once(args.address, args.network, state, tg_token, tg_chat_id,
                      hook_token, hook_path, gw_port, worker_cfg)
            time.sleep(args.poll_interval)

    print(f"  Connection:  WebSocket → polling fallback", flush=True)
    print(flush=True)

    ws_available  = True
    reconnect_delay = 5

    while True:
        state = load_state()

        if ws_available:
            should_reconnect = run_websocket(
                args.address, args.network, state, tg_token, tg_chat_id,
                hook_token, hook_path, gw_port, worker_cfg
            )
            if should_reconnect:
                log(f"Reconnecting in {reconnect_delay}s...")
                time.sleep(reconnect_delay)
                reconnect_delay = min(reconnect_delay * 2, 60)
                continue
            else:
                log(f"Node unavailable — polling every {args.poll_interval}s")
                ws_available = False
                reconnect_delay = 5

        poll_once(args.address, args.network, state, tg_token, tg_chat_id,
                  hook_token, hook_path, gw_port, worker_cfg)
        time.sleep(args.poll_interval)

        # Check if node came back online
        try:
            s = socket.create_connection((WS_HOST, WS_PORT), timeout=3)
            s.close()
            log("Node back online — switching to WebSocket")
            ws_available = True
        except OSError:
            pass


if __name__ == "__main__":
    main()


path: scripts/protocol.py
content: """
SignaAI protocol messages.

This module is intentionally network-free. It gives developers stable,
structured builders and parsers for the compact on-chain messages used by the
SDK, examples, indexers, and future OpenClaw skills.
"""
from dataclasses import dataclass


PROOF_PREFIX = "SIGPROOF:v1:"
ESCROW_PREFIX = "ESCROW:"
TASK_PREFIX = "TASK:"
TASK_COMPLETE_PREFIX = "TASK_COMPLETE:"
ARBIT_OPEN_PREFIX = "ARBIT_OPEN:"
ARBIT_VOTE_PREFIX = "ARBIT_VOTE:"
ARBIT_CLOSE_PREFIX = "ARBIT_CLOSE:"


class ProtocolError(ValueError):
    """Raised when a SignaAI protocol message cannot be parsed."""


@dataclass(frozen=True)
class SigProof:
    content_hash: str
    sources_hash: str = ""
    label: str = ""
    version: str = "v1"

    kind = "sigproof"

    def to_message(self):
        return build_sigproof(self.content_hash, self.sources_hash, self.label)


@dataclass(frozen=True)
class EscrowMessage:
    action: str
    escrow_id: str
    version: str = ""
    worker: str = ""
    amount_nqt: int = 0
    task_hash: str = ""
    deadline_block: int = 0
    operator: str = ""
    result_hash: str = ""
    proof_tx: str = ""
    participant: str = ""
    task_description: str = ""

    kind = "escrow"

    def to_message(self):
        return build_escrow_message(self)


@dataclass(frozen=True)
class TaskComplete:
    task_id: str
    result_hash: str
    rating: int

    kind = "task_complete"

    def to_message(self):
        return build_task_complete(self.task_id, self.result_hash, self.rating)


@dataclass(frozen=True)
class ArbitrationMessage:
    action: str
    escrow_id: str
    claimant: str = ""
    arbitrator: str = ""
    decision: str = ""
    reason_hash: str = ""
    notes_hash: str = ""

    kind = "arbitration"

    def to_message(self):
        return build_arbitration_message(self)


@dataclass(frozen=True)
class TaskMessage:
    """
    A TASK board protocol message.

    action: OPEN | CLAIM | ACCEPT | CANCEL
    """
    action: str
    task_id: str
    version: str = "v1"
    payer_address: str = ""
    capability_tag: str = ""
    amount_nqt: int = 0
    deadline_block: int = 0
    task_hash: str = ""
    worker_address: str = ""
    task_summary: str = ""

    kind = "task"

    def to_message(self):
        return build_task_message(self)


@dataclass(frozen=True)
class UnknownMessage:
    raw: str

    kind = "unknown"

    def to_message(self):
        return self.raw


def sanitize_label(label, limit=40):
    """Make a label safe for colon-delimited on-chain messages."""
    return (label or "")[:limit].replace(":", "-")


def parse_message(message, strict=False):
    """Parse any known SignaAI protocol message."""
    if message.startswith(PROOF_PREFIX):
        return parse_sigproof(message)
    if message.startswith(ESCROW_PREFIX):
        return parse_escrow(message)
    if message.startswith(TASK_PREFIX) and not message.startswith(TASK_COMPLETE_PREFIX):
        return parse_task(message)
    if message.startswith(TASK_COMPLETE_PREFIX):
        return parse_task_complete(message)
    if (message.startswith(ARBIT_OPEN_PREFIX) or
            message.startswith(ARBIT_VOTE_PREFIX) or
            message.startswith(ARBIT_CLOSE_PREFIX)):
        return parse_arbitration(message)
    if strict:
        raise ProtocolError("Unknown SignaAI protocol message")
    return UnknownMessage(message)


def build_sigproof(content_hash, sources_hash="", label=""):
    return f"{PROOF_PREFIX}{content_hash}:{sources_hash}:{sanitize_label(label)}"


def parse_sigproof(message):
    if not message.startswith(PROOF_PREFIX):
        raise ProtocolError("Not a SIGPROOF message")
    parts = message[len(PROOF_PREFIX):].split(":")
    if len(parts) < 2:
        raise ProtocolError("Malformed SIGPROOF message")
    return SigProof(
        content_hash=parts[0],
        sources_hash=parts[1],
        label=":".join(parts[2:]) if len(parts) > 2 else "",
    )


def build_escrow_create(escrow_id, worker, amount_nqt, task_hash,
                        deadline_block, operator="", task_description=""):
    msg = f"{ESCROW_PREFIX}CREATE:{escrow_id}:{worker}:{int(amount_nqt)}:{task_hash}:{int(deadline_block)}"
    if task_description:
        # Always include operator field (possibly empty) so task_description is at a fixed index
        return f"{msg}:{operator}:{task_description}"
    return f"{msg}:{operator}" if operator else msg


def build_escrow_fund(escrow_id):
    return f"{ESCROW_PREFIX}FUND:{escrow_id}"


def build_escrow_submit(escrow_id, result_hash, proof_tx=""):
    msg = f"{ESCROW_PREFIX}SUBMIT:{escrow_id}:{result_hash}"
    return f"{msg}:{proof_tx}" if proof_tx else msg


def build_escrow_release(escrow_id, worker):
    return f"{ESCROW_PREFIX}RELEASE:{escrow_id}:{worker}"


def build_escrow_refund(escrow_id, payer):
    return f"{ESCROW_PREFIX}REFUND:{escrow_id}:{payer}"


def build_escrow_assign(escrow_id, task_hash, task_description="",
                        version="v1"):
    if version:
        return f"{ESCROW_PREFIX}ASSIGN:{version}:{escrow_id}:{task_hash}:{task_description}"
    return f"{ESCROW_PREFIX}ASSIGN:{escrow_id}:{task_hash}:{task_description}"


def build_escrow_message(msg):
    action = msg.action.upper()
    if action == "CREATE":
        return build_escrow_create(
            msg.escrow_id, msg.worker, msg.amount_nqt, msg.task_hash,
            msg.deadline_block, msg.operator, msg.task_description
        )
    if action == "FUND":
        return build_escrow_fund(msg.escrow_id)
    if action == "SUBMIT":
        return build_escrow_submit(msg.escrow_id, msg.result_hash, msg.proof_tx)
    if action == "RELEASE":
        return build_escrow_release(msg.escrow_id, msg.worker or msg.participant)
    if action == "REFUND":
        return build_escrow_refund(msg.escrow_id, msg.participant)
    if action == "ASSIGN":
        return build_escrow_assign(
            msg.escrow_id, msg.task_hash, msg.task_description, msg.version
        )
    raise ProtocolError(f"Unsupported escrow action: {msg.action}")


def parse_escrow(message):
    if not message.startswith(ESCROW_PREFIX):
        raise ProtocolError("Not an ESCROW message")

    parts = message[len(ESCROW_PREFIX):].split(":")
    if len(parts) < 2:
        raise ProtocolError("Malformed ESCROW message")

    action = parts[0].upper()
    version = parts[1] if len(parts) > 2 and parts[1].startswith("v") else ""
    payload = parts[2:] if version else parts[1:]
    if not payload:
        raise ProtocolError("ESCROW message missing escrow id")

    escrow_id = payload[0]
    if action == "CREATE":
        if len(payload) < 5:
            raise ProtocolError("Malformed ESCROW CREATE message")
        return EscrowMessage(
            action=action,
            escrow_id=escrow_id,
            version=version,
            worker=payload[1],
            amount_nqt=_int_or_zero(payload[2]),
            task_hash=payload[3],
            deadline_block=_int_or_zero(payload[4]),
            operator=payload[5] if len(payload) > 5 else "",
            task_description=":".join(payload[6:]) if len(payload) > 6 else "",
        )
    if action == "FUND":
        return EscrowMessage(action=action, escrow_id=escrow_id, version=version)
    if action == "SUBMIT":
        if len(payload) < 2:
            raise ProtocolError("Malformed ESCROW SUBMIT message")
        return EscrowMessage(
            action=action,
            escrow_id=escrow_id,
            version=version,
            result_hash=payload[1],
            proof_tx=payload[2] if len(payload) > 2 else "",
        )
    if action == "RELEASE":
        return EscrowMessage(
            action=action,
            escrow_id=escrow_id,
            version=version,
            worker=payload[1] if len(payload) > 1 else "",
            participant=payload[1] if len(payload) > 1 else "",
        )
    if action == "REFUND":
        return EscrowMessage(
            action=action,
            escrow_id=escrow_id,
            version=version,
            participant=payload[1] if len(payload) > 1 else "",
        )
    if action == "ASSIGN":
        if len(payload) < 2:
            raise ProtocolError("Malformed ESCROW ASSIGN message")
        return EscrowMessage(
            action=action,
            escrow_id=escrow_id,
            version=version,
            task_hash=payload[1],
            task_description=":".join(payload[2:]) if len(payload) > 2 else "",
        )
    raise ProtocolError(f"Unsupported escrow action: {action}")


def build_task_complete(task_id, result_hash, rating):
    try:
        rating = int(rating)
    except (TypeError, ValueError) as exc:
        raise ProtocolError("Rating must be an integer") from exc
    if rating < 1 or rating > 5:
        raise ProtocolError("Rating must be between 1 and 5")
    return f"{TASK_COMPLETE_PREFIX}{task_id}:{result_hash}:{rating}"


def parse_task_complete(message):
    if not message.startswith(TASK_COMPLETE_PREFIX):
        raise ProtocolError("Not a TASK_COMPLETE message")
    parts = message[len(TASK_COMPLETE_PREFIX):].split(":")
    if len(parts) < 3:
        raise ProtocolError("Malformed TASK_COMPLETE message")
    try:
        rating = int(parts[2])
    except ValueError as exc:
        raise ProtocolError("Rating must be an integer") from exc
    if rating < 1 or rating > 5:
        raise ProtocolError("Rating must be between 1 and 5")
    return TaskComplete(
        task_id=parts[0],
        result_hash=parts[1],
        rating=rating,
    )


def build_arbit_open(escrow_id, claimant, reason_hash):
    return f"{ARBIT_OPEN_PREFIX}{escrow_id}:{claimant}:{reason_hash}"


def build_arbit_vote(escrow_id, decision, notes_hash="none"):
    decision = decision.upper()
    if decision not in ("RELEASE", "REFUND"):
        raise ProtocolError("Decision must be RELEASE or REFUND")
    return f"{ARBIT_VOTE_PREFIX}{escrow_id}:{decision}:{notes_hash or 'none'}"


def build_arbit_close(escrow_id, decision, arbitrator):
    decision = decision.upper()
    if decision not in ("RELEASE", "REFUND"):
        raise ProtocolError("Decision must be RELEASE or REFUND")
    return f"{ARBIT_CLOSE_PREFIX}{escrow_id}:{decision}:{arbitrator}"


def build_arbitration_message(msg):
    action = msg.action.upper()
    if action == "OPEN":
        return build_arbit_open(msg.escrow_id, msg.claimant, msg.reason_hash)
    if action == "VOTE":
        return build_arbit_vote(msg.escrow_id, msg.decision, msg.notes_hash)
    if action == "CLOSE":
        return build_arbit_close(msg.escrow_id, msg.decision, msg.arbitrator)
    raise ProtocolError(f"Unsupported arbitration action: {msg.action}")


def parse_arbitration(message):
    if message.startswith(ARBIT_OPEN_PREFIX):
        parts = message[len(ARBIT_OPEN_PREFIX):].split(":")
        if len(parts) < 3:
            raise ProtocolError("Malformed ARBIT_OPEN message")
        return ArbitrationMessage(
            action="OPEN",
            escrow_id=parts[0],
            claimant=parts[1],
            reason_hash=parts[2],
        )
    if message.startswith(ARBIT_VOTE_PREFIX):
        parts = message[len(ARBIT_VOTE_PREFIX):].split(":")
        if len(parts) < 3:
            raise ProtocolError("Malformed ARBIT_VOTE message")
        return ArbitrationMessage(
            action="VOTE",
            escrow_id=parts[0],
            decision=parts[1],
            notes_hash=parts[2],
        )
    if message.startswith(ARBIT_CLOSE_PREFIX):
        parts = message[len(ARBIT_CLOSE_PREFIX):].split(":")
        if len(parts) < 3:
            raise ProtocolError("Malformed ARBIT_CLOSE message")
        return ArbitrationMessage(
            action="CLOSE",
            escrow_id=parts[0],
            decision=parts[1],
            arbitrator=parts[2],
        )
    raise ProtocolError("Not an arbitration message")


def _int_or_zero(value):
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0


# ── TASK board protocol ───────────────────────────────────────────────────────

def build_task_open(task_id, payer_address, capability_tag,
                    amount_nqt, deadline_block, task_hash,
                    version="v1", task_summary=""):
    """TASK:OPEN:v1:<task_id>:<payer>:<capability>:<amount_nqt>:<deadline_block>:<task_hash>[:<summary>]"""
    cap = (capability_tag or "").replace(":", "-")
    base = (f"{TASK_PREFIX}OPEN:{version}:{task_id}:{payer_address}"
            f":{cap}:{int(amount_nqt)}:{int(deadline_block)}:{task_hash}")
    if task_summary:
        return f"{base}:{sanitize_label(task_summary, 200)}"
    return base


def build_task_claim(task_id, worker_address, version="v1"):
    """TASK:CLAIM:v1:<task_id>:<worker_address>"""
    return f"{TASK_PREFIX}CLAIM:{version}:{task_id}:{worker_address}"


def build_task_accept(task_id, worker_address, version="v1"):
    """TASK:ACCEPT:v1:<task_id>:<worker_address>"""
    return f"{TASK_PREFIX}ACCEPT:{version}:{task_id}:{worker_address}"


def build_task_cancel(task_id, version="v1"):
    """TASK:CANCEL:v1:<task_id>"""
    return f"{TASK_PREFIX}CANCEL:{version}:{task_id}"


def build_task_message(msg):
    action = msg.action.upper()
    if action == "OPEN":
        return build_task_open(msg.task_id, msg.payer_address, msg.capability_tag,
                               msg.amount_nqt, msg.deadline_block, msg.task_hash,
                               msg.version)
    if action == "CLAIM":
        return build_task_claim(msg.task_id, msg.worker_address, msg.version)
    if action == "ACCEPT":
        return build_task_accept(msg.task_id, msg.worker_address, msg.version)
    if action == "CANCEL":
        return build_task_cancel(msg.task_id, msg.version)
    raise ProtocolError(f"Unsupported task action: {action}")


def parse_task(message):
    """Parse a TASK: board protocol message."""
    if not message.startswith(TASK_PREFIX):
        raise ProtocolError("Not a TASK message")
    parts = message[len(TASK_PREFIX):].split(":")
    if len(parts) < 2:
        raise ProtocolError("Malformed TASK message")

    action = parts[0].upper()
    version = parts[1] if len(parts) > 2 and parts[1].startswith("v") else "v1"
    payload = parts[2:] if version else parts[1:]

    if not payload:
        raise ProtocolError("TASK message missing task_id")

    task_id = payload[0]

    if action == "OPEN":
        # payload: task_id, payer, capability, amount_nqt, deadline_block, task_hash[, summary]
        if len(payload) < 6:
            raise ProtocolError("Malformed TASK:OPEN message")
        return TaskMessage(
            action=action,
            task_id=task_id,
            version=version,
            payer_address=payload[1],
            capability_tag=payload[2],
            amount_nqt=_int_or_zero(payload[3]),
            deadline_block=_int_or_zero(payload[4]),
            task_hash=payload[5],
            task_summary=payload[6] if len(payload) > 6 else "",
        )
    if action in ("CLAIM", "ACCEPT"):
        if len(payload) < 2:
            raise ProtocolError(f"Malformed TASK:{action} message")
        return TaskMessage(
            action=action,
            task_id=task_id,
            version=version,
            worker_address=payload[1],
        )
    if action == "CANCEL":
        return TaskMessage(action=action, task_id=task_id, version=version)

    raise ProtocolError(f"Unsupported task action: {action}")


path: scripts/run.sh
content: #!/bin/bash
cd "$(dirname "$0")"

# Check AT balance directly
python3 wallet.py --network mainnet balance S-FH56-5NA6-E75H-DPAM9
echo ""
# Check worker balance
python3 wallet.py --network mainnet balance S-44S7-32XB-5DM5-5AL3K


path: scripts/signum_api.py
content: #!/usr/bin/env python3
"""
Signum API Client — base layer for all Signum skill scripts.
Handles node communication, NQT conversion, and error handling.
"""
import json
import urllib.request
import urllib.parse
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_DOWN

# ── Constants ────────────────────────────────────────────────────────────────
NQT = 100_000_000          # 1 SIGNA = 100,000,000 NQT (Nano-Quant)
FEE_STANDARD  = 2_000_000   # 0.02 SIGNA — standard transaction fee
FEE_MESSAGE   = 5_000_000   # 0.05 SIGNA — minimum for message transactions (node raised minimum)
FEE_ALIAS     = 20_000_000  # 0.2 SIGNA — alias registration fee
FEE_AT        = 2_000_000   # 0.02 SIGNA — minimum fee for AT (smart contract) transactions

def fee_message(message=""):
    """Dynamic message fee: 0.05 SIGNA base + 0.01 per 1KB. Min 0.05 (current node minimum)."""
    msg_bytes = len(message.encode("utf-8")) if message else 0
    chunks = max(1, -(-msg_bytes // 1024))  # ceiling division
    return max(FEE_MESSAGE, chunks * 1_000_000)
DEADLINE      = 1440       # minutes — max transaction validity window
USER_AGENT    = "SignaAI/0.1.0"

EXPLORER_URL = "https://explorer.signum.network"

NODES = {
    "mainnet": [
        "https://europe.signum.network",
        "https://us.signum.network",
        "https://brazil.signum.network",
    ],
    "testnet": [
        "https://europe3.testnet.signum.network",
    ]
}

# ── Client ───────────────────────────────────────────────────────────────────
class SignumAPI:
    def __init__(self, network="testnet"):
        self.network = network
        self.nodes = NODES[network]
        self.active_node = self.nodes[0]

    def _call(self, params, method="GET", retries=2):
        if "secretPhrase" in params:
            return {"error": "Refusing to send secretPhrase to node; local signing is required"}

        url = f"{self.active_node}/api"
        for attempt in range(retries):
            try:
                if method == "GET":
                    query = urllib.parse.urlencode(params)
                    req = urllib.request.Request(
                        f"{url}?{query}",
                        headers={"User-Agent": USER_AGENT}
                    )
                else:
                    data = urllib.parse.urlencode(params).encode()
                    req = urllib.request.Request(
                        url, data=data,
                        headers={"User-Agent": USER_AGENT,
                                 "Content-Type": "application/x-www-form-urlencoded"}
                    )
                with urllib.request.urlopen(req, timeout=15) as resp:
                    result = json.loads(resp.read())
                    if "errorDescription" in result:
                        return {"error": result["errorDescription"], "errorCode": result.get("errorCode")}
                    return result
            except Exception as e:
                if attempt == retries - 1:
                    # Try next node
                    idx = self.nodes.index(self.active_node)
                    if idx + 1 < len(self.nodes):
                        self.active_node = self.nodes[idx + 1]
                        return self._call(params, method, retries=1)
                    return {"error": str(e)}
        return {"error": "All nodes failed"}

    def get(self, request_type, **params):
        params["requestType"] = request_type
        return self._call(params, "GET")

    def post(self, request_type, **params):
        params["requestType"] = request_type
        params["deadline"] = DEADLINE
        # If secretPhrase is present, sign locally and broadcast instead of sending passphrase
        if "secretPhrase" in params:
            return self._sign_and_broadcast(request_type, params)
        return self._call(params, "POST")

    def _sign_and_broadcast(self, request_type, params):
        """Local signing flow — passphrase never leaves this machine."""
        try:
            from signum_crypto import generate_sign_keys, generate_signature, generate_signed_transaction_bytes
        except ImportError as exc:
            return {"error": f"Local signing unavailable: {exc}"}

        passphrase = params.pop("secretPhrase")
        keys = generate_sign_keys(passphrase)
        params["publicKey"] = keys["publicKey"]

        # Get unsigned transaction bytes from node
        result = self._call(params, "POST")
        if "error" in result:
            return result
        unsigned_hex = result.get("unsignedTransactionBytes")
        if not unsigned_hex:
            return result  # Some calls return directly (e.g. broadcastTransaction)

        # Sign locally and broadcast
        signature = generate_signature(unsigned_hex, keys["signPrivateKey"])
        signed_hex = generate_signed_transaction_bytes(unsigned_hex, signature)
        broadcast = self._call({"requestType": "broadcastTransaction",
                                "transactionBytes": signed_hex}, "POST")
        if "error" in broadcast:
            return broadcast
        # Return same shape callers expect
        result.update(broadcast)
        return result

# ── Helpers ──────────────────────────────────────────────────────────────────
def signa(nqt):
    """Convert NQT to SIGNA float."""
    return int(nqt) / NQT if nqt else 0

def nqt(amount_signa):
    """Convert SIGNA to NQT int. Uses Decimal to avoid float precision errors."""
    try:
        amount = Decimal(str(amount_signa))
    except (InvalidOperation, ValueError) as exc:
        raise ValueError(f"Invalid SIGNA amount: {amount_signa!r}") from exc
    if amount < 0:
        raise ValueError("SIGNA amount cannot be negative")
    amount = amount.quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
    return int(amount * NQT)

def ts(timestamp_signum):
    """Convert Signum genesis-relative timestamp to datetime string.
    Signum genesis: 2014-08-11 02:00:00 UTC"""
    genesis = 1407715200  # Unix timestamp of Signum genesis
    if not timestamp_signum:
        return "—"
    try:
        unix = genesis + int(timestamp_signum)
        return datetime.fromtimestamp(unix).strftime("%Y-%m-%d %H:%M")
    except:
        return str(timestamp_signum)

def fmt_address(addr):
    """Ensure address has SIGNA- prefix."""
    if addr and not str(addr).startswith("SIGNA-"):
        return f"SIGNA-{addr}"
    return addr

def ok(result):
    """Check if API result is successful."""
    return result and "error" not in result

# ── Singleton ────────────────────────────────────────────────────────────────
_api_instance = None

def get_api(network=None):
    global _api_instance
    if _api_instance is None or (network and _api_instance.network != network):
        import os
        net = network or os.environ.get("SIGNUM_NETWORK", "testnet")
        _api_instance = SignumAPI(net)
    return _api_instance

if __name__ == "__main__":
    api = get_api("mainnet")
    status = api.get("getBlockchainStatus")
    print(f"Network:  {api.network}")
    print(f"Node:     {api.active_node}")
    print(f"Blocks:   {status.get('numberOfBlocks', '?'):,}")
    print(f"Version:  {status.get('version', '?')}")


path: scripts/signum_crypto.py
content: """
signum_crypto.py — Pure-Python local transaction signing for the Signum blockchain.

Ported faithfully from signumjs:
  packages/crypto/src/base/curve25519.ts
  packages/crypto/src/base/ec-kcdsa.ts

No external dependencies — stdlib only (hashlib).
"""

import hashlib


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _trunc16(v):
    """JavaScript (v / 0x10000) | 0  — truncate toward zero, then take lower 32 bits."""
    return int(v / 0x10000)


def _to_int32(v):
    """Truncate Python int to 32-bit signed integer (mirrors JS | 0 and << behavior)."""
    v = v & 0xFFFFFFFF
    if v >= 0x80000000:
        v -= 0x100000000
    return v


def _sar32(v, n):
    """Arithmetic right shift of a 32-bit signed integer by n bits (mirrors JS >>)."""
    # Ensure v is int32 first
    v = _to_int32(v)
    if v < 0:
        # Python arithmetic right shift preserves sign on arbitrary-precision ints
        return v >> n
    return v >> n


def _sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


# ---------------------------------------------------------------------------
# Curve25519
# ---------------------------------------------------------------------------

class Curve25519:
    KEY_SIZE     = 32
    UNPACKED_SIZE = 16   # JS Uint16Array of 16 elements

    # group order (a prime near 2^252+2^124)
    ORDER = [
        237, 211, 245,  92,
         26,  99,  18,  88,
        214, 156, 247, 162,
        222, 249, 222,  20,
          0,   0,   0,   0,
          0,   0,   0,   0,
          0,   0,   0,   0,
          0,   0,   0,  16,
    ]

    # smallest multiple of the order that's >= 2^255
    ORDER_TIMES_8 = [
        104, 159, 174, 231,
        210,  24, 147, 192,
        178, 230, 188,  23,
        245, 206, 247, 166,
          0,   0,   0,   0,
          0,   0,   0,   0,
          0,   0,   0,   0,
          0,   0,   0, 128,
    ]

    # constants 2Gy and 1/(2Gy) — 16-element unpacked arrays
    BASE_2Y = [
        22587,   610, 29883, 44076,
        15515,  9479, 25859, 56197,
        23910,  4462, 17831, 16322,
        62102, 36542, 52412, 16035,
    ]

    BASE_R2Y = [
         5744, 16384, 61977, 54121,
         8776, 18501, 26522, 34893,
        23833,  5823, 55924, 58749,
        24147, 14085, 13606,  6080,
    ]

    C1      = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    C9      = [9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    C486671 = [0x6D0F, 0x0007, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    C39420360 = [0x81C8, 0x0259, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

    P25 = 33554431   # (1 << 25) - 1
    P26 = 67108863   # (1 << 26) - 1

    # ------------------------------------------------------------------
    # Key agreement
    # ------------------------------------------------------------------

    @staticmethod
    def clamp(k):
        k[31] &= 0x7F
        k[31] |= 0x40
        k[0]  &= 0xF8

    # ------------------------------------------------------------------
    # radix 2^8 math
    # ------------------------------------------------------------------

    @staticmethod
    def cpy32(d, s):
        for i in range(32):
            d[i] = s[i]

    @staticmethod
    def mula_small(p, q, m, x, n, z):
        """p[m..n+m-1] = q[m..n+m-1] + z * x;  returns carry."""
        v = 0
        for i in range(n):
            v += (q[i + m] & 0xFF) + z * (x[i] & 0xFF)
            p[i + m] = v & 0xFF
            v >>= 8
        return v

    @staticmethod
    def mula32(p, x, y, t, z):
        """p += x * y * z  where z is small; x size 32, y size t, p size 32+t."""
        n = 31
        w = 0
        i = 0
        for i in range(t):
            zy = z * (y[i] & 0xFF)
            w += Curve25519.mula_small(p, p, i, x, n, zy) + (p[i + n] & 0xFF) + zy * (x[n] & 0xFF)
            p[i + n] = w & 0xFF
            w >>= 8
        # In JS, the for-loop exits with i = t (post-increment), not t-1
        # So the final assignment uses p[t + n], not p[(t-1) + n]
        i = t
        p[i + n] = (w + (p[i + n] & 0xFF)) & 0xFF
        return w >> 8

    @staticmethod
    def divmod(q, r, n, d, t):
        """Divide r (size n) by d (size t); quotient in q, remainder in r."""
        rn = 0
        dt = (d[t - 1] & 0xFF) << 8
        if t > 1:
            dt |= (d[t - 2] & 0xFF)
        while n >= t:
            n -= 1
            z = (rn << 16) | ((r[n] & 0xFF) << 8)
            if n > 0:
                z |= (r[n - 1] & 0xFF)
            i = n - t + 1
            z = int(z / dt)   # truncate toward zero
            rn += Curve25519.mula_small(r, r, i, d, t, -z)
            q[i] = (z + rn) & 0xFF
            Curve25519.mula_small(r, r, i, d, t, -rn)
            rn = r[n] & 0xFF
            r[n] = 0
        r[t - 1] = rn & 0xFF

    @staticmethod
    def numsize(x, n):
        while n != 0 and x[n - 1] == 0:
            n -= 1
        return n

    @staticmethod
    def egcd32(x, y, a, b):
        """Extended GCD; returns x or y holding inv(a) mod b as 32-byte signed."""
        for i in range(32):
            x[i] = y[i] = 0
        x[0] = 1
        an = Curve25519.numsize(a, 32)
        if an == 0:
            return y
        bn = 32
        temp = [0] * 32
        while True:
            qn = bn - an + 1
            Curve25519.divmod(temp, b, bn, a, an)
            bn = Curve25519.numsize(b, bn)
            if bn == 0:
                return x
            Curve25519.mula32(y, x, temp, qn, -1)

            qn = an - bn + 1
            Curve25519.divmod(temp, a, an, b, bn)
            an = Curve25519.numsize(a, an)
            if an == 0:
                return y
            Curve25519.mula32(x, y, temp, qn, -1)

    # ------------------------------------------------------------------
    # radix 2^25.5 GF(2^255-19) math — pack / unpack
    # ------------------------------------------------------------------

    @staticmethod
    def createUnpackedArray():
        """16-element list of ints (mirrors Uint16Array)."""
        return [0] * 16

    @staticmethod
    def unpack(x, m):
        """Convert 32-byte little-endian list to internal 16-element form."""
        for i in range(0, Curve25519.KEY_SIZE, 2):
            x[i // 2] = (m[i] & 0xFF) | ((m[i + 1] & 0xFF) << 8)

    @staticmethod
    def is_overflow(x):
        return (
            (x[0] > Curve25519.P26 - 19)
            and (x[1] & x[3] & x[5] & x[7] & x[9]) == Curve25519.P25
            and (x[2] & x[4] & x[6] & x[8]) == Curve25519.P26
        ) or (x[9] > Curve25519.P25)

    @staticmethod
    def pack(x, m):
        """Convert internal 16-element form to 32-byte little-endian list."""
        # Ensure m has 32 slots
        while len(m) < 32:
            m.append(0)
        for i in range(Curve25519.UNPACKED_SIZE):
            if 2 * i >= len(m):
                m.append(x[i] & 0x00FF)
            else:
                m[2 * i] = x[i] & 0x00FF
            if 2 * i + 1 >= len(m):
                m.append((x[i] & 0xFF00) >> 8)
            else:
                m[2 * i + 1] = (x[i] & 0xFF00) >> 8

    @staticmethod
    def cpy(d, s):
        for i in range(Curve25519.UNPACKED_SIZE):
            d[i] = s[i]

    @staticmethod
    def set(d, s):
        d[0] = s
        for i in range(1, Curve25519.UNPACKED_SIZE):
            d[i] = 0

    # ------------------------------------------------------------------
    # Field arithmetic
    # ------------------------------------------------------------------

    @staticmethod
    def c255lsqr8h(a7, a6, a5, a4, a3, a2, a1, a0):
        r = [0] * 16
        v = a0 * a0
        r[0] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a1
        r[1] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a2 + a1 * a1
        r[2] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a3 + 2 * a1 * a2
        r[3] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a4 + 2 * a1 * a3 + a2 * a2
        r[4] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a5 + 2 * a1 * a4 + 2 * a2 * a3
        r[5] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a6 + 2 * a1 * a5 + 2 * a2 * a4 + a3 * a3
        r[6] = v & 0xFFFF

        v = _trunc16(v) + 2 * a0 * a7 + 2 * a1 * a6 + 2 * a2 * a5 + 2 * a3 * a4
        r[7] = v & 0xFFFF

        v = _trunc16(v) + 2 * a1 * a7 + 2 * a2 * a6 + 2 * a3 * a5 + a4 * a4
        r[8] = v & 0xFFFF

        v = _trunc16(v) + 2 * a2 * a7 + 2 * a3 * a6 + 2 * a4 * a5
        r[9] = v & 0xFFFF

        v = _trunc16(v) + 2 * a3 * a7 + 2 * a4 * a6 + a5 * a5
        r[10] = v & 0xFFFF

        v = _trunc16(v) + 2 * a4 * a7 + 2 * a5 * a6
        r[11] = v & 0xFFFF

        v = _trunc16(v) + 2 * a5 * a7 + a6 * a6
        r[12] = v & 0xFFFF

        v = _trunc16(v) + 2 * a6 * a7
        r[13] = v & 0xFFFF

        v = _trunc16(v) + a7 * a7
        r[14] = v & 0xFFFF

        r[15] = _trunc16(v)
        return r

    @staticmethod
    def sqr(r, a):
        x = Curve25519.c255lsqr8h(a[15], a[14], a[13], a[12], a[11], a[10], a[9], a[8])
        z = Curve25519.c255lsqr8h(a[7],  a[6],  a[5],  a[4],  a[3],  a[2],  a[1], a[0])
        y = Curve25519.c255lsqr8h(
            a[15] + a[7], a[14] + a[6], a[13] + a[5], a[12] + a[4],
            a[11] + a[3], a[10] + a[2], a[9]  + a[1], a[8]  + a[0],
        )

        v = 0x800000 + z[0] + (y[8] - x[8] - z[8] + x[0] - 0x80) * 38
        r[0] = v & 0xFFFF

        for i in range(1, 8):
            v = 0x7fff80 + _trunc16(v) + z[i] + (y[8 + i] - x[8 + i] - z[8 + i] + x[i]) * 38
            r[i] = v & 0xFFFF

        v = 0x7fff80 + _trunc16(v) + z[8] + y[0] - x[0] - z[0] + x[8] * 38
        r[8] = v & 0xFFFF

        for i in range(9, 15):
            v = 0x7fff80 + _trunc16(v) + z[i] + y[i - 8] - x[i - 8] - z[i - 8] + x[i] * 38
            r[i] = v & 0xFFFF

        r15 = 0x7fff80 + _trunc16(v) + z[15] + y[7] - x[7] - z[7] + x[15] * 38
        Curve25519.c255lreduce(r, r15)

    @staticmethod
    def c255lmul8h(a7, a6, a5, a4, a3, a2, a1, a0,
                   b7, b6, b5, b4, b3, b2, b1, b0):
        r = [0] * 16
        v = a0 * b0
        r[0] = v & 0xFFFF

        v = _trunc16(v) + a0 * b1 + a1 * b0
        r[1] = v & 0xFFFF

        v = _trunc16(v) + a0 * b2 + a1 * b1 + a2 * b0
        r[2] = v & 0xFFFF

        v = _trunc16(v) + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0
        r[3] = v & 0xFFFF

        v = _trunc16(v) + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0
        r[4] = v & 0xFFFF

        v = _trunc16(v) + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0
        r[5] = v & 0xFFFF

        v = _trunc16(v) + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0
        r[6] = v & 0xFFFF

        v = (_trunc16(v) + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4
             + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0)
        r[7] = v & 0xFFFF

        v = _trunc16(v) + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1
        r[8] = v & 0xFFFF

        v = _trunc16(v) + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2
        r[9] = v & 0xFFFF

        v = _trunc16(v) + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3
        r[10] = v & 0xFFFF

        v = _trunc16(v) + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4
        r[11] = v & 0xFFFF

        v = _trunc16(v) + a5 * b7 + a6 * b6 + a7 * b5
        r[12] = v & 0xFFFF

        v = _trunc16(v) + a6 * b7 + a7 * b6
        r[13] = v & 0xFFFF

        v = _trunc16(v) + a7 * b7
        r[14] = v & 0xFFFF

        r[15] = _trunc16(v)
        return r

    @staticmethod
    def mul(r, a, b):
        x = Curve25519.c255lmul8h(
            a[15], a[14], a[13], a[12], a[11], a[10], a[9], a[8],
            b[15], b[14], b[13], b[12], b[11], b[10], b[9], b[8],
        )
        z = Curve25519.c255lmul8h(
            a[7], a[6], a[5], a[4], a[3], a[2], a[1], a[0],
            b[7], b[6], b[5], b[4], b[3], b[2], b[1], b[0],
        )
        y = Curve25519.c255lmul8h(
            a[15] + a[7], a[14] + a[6], a[13] + a[5], a[12] + a[4],
            a[11] + a[3], a[10] + a[2], a[9]  + a[1], a[8]  + a[0],
            b[15] + b[7], b[14] + b[6], b[13] + b[5], b[12] + b[4],
            b[11] + b[3], b[10] + b[2], b[9]  + b[1], b[8]  + b[0],
        )

        v = 0x800000 + z[0] + (y[8] - x[8] - z[8] + x[0] - 0x80) * 38
        r[0] = v & 0xFFFF

        for i in range(1, 8):
            v = 0x7fff80 + _trunc16(v) + z[i] + (y[8 + i] - x[8 + i] - z[8 + i] + x[i]) * 38
            r[i] = v & 0xFFFF

        v = 0x7fff80 + _trunc16(v) + z[8] + y[0] - x[0] - z[0] + x[8] * 38
        r[8] = v & 0xFFFF

        for i in range(9, 15):
            v = 0x7fff80 + _trunc16(v) + z[i] + y[i - 8] - x[i - 8] - z[i - 8] + x[i] * 38
            r[i] = v & 0xFFFF

        r15 = 0x7fff80 + _trunc16(v) + z[15] + y[7] - x[7] - z[7] + x[15] * 38
        Curve25519.c255lreduce(r, r15)

    @staticmethod
    def c255lreduce(a, a15):
        v = a15
        a[15] = v & 0x7FFF
        v = _trunc16(v >> 15) * 19     # (v / 0x8000) | 0  then * 19
        # re-express: _trunc16(v >> 15) is wrong — need trunc(v / 0x8000)
        # Redo: the JS is  ((v / 0x8000) | 0) * 19
        # We already did a[15] = v & 0x7FFF, now carry = trunc(v / 0x8000)
        carry = int(a15 / 0x8000)   # truncate-toward-zero division
        a[15] = a15 & 0x7FFF
        v = carry * 19
        for i in range(15):
            v += a[i]
            a[i] = v & 0xFFFF
            v = _trunc16(v)
        a[15] += v

    @staticmethod
    def add(r, a, b):
        v = (int(a[15] / 0x8000) + int(b[15] / 0x8000)) * 19 + a[0] + b[0]
        r[0] = v & 0xFFFF
        for i in range(1, 15):
            v = _trunc16(v) + a[i] + b[i]
            r[i] = v & 0xFFFF
        r[15] = _trunc16(v) + (a[15] & 0x7FFF) + (b[15] & 0x7FFF)

    @staticmethod
    def sub(r, a, b):
        v = 0x80000 + (int(a[15] / 0x8000) - int(b[15] / 0x8000) - 1) * 19 + a[0] - b[0]
        r[0] = v & 0xFFFF
        for i in range(1, 15):
            v = _trunc16(v) + 0x7fff8 + a[i] - b[i]
            r[i] = v & 0xFFFF
        r[15] = _trunc16(v) + 0x7ff8 + (a[15] & 0x7FFF) - (b[15] & 0x7FFF)

    @staticmethod
    def mul_small(r, a, m):
        v = a[0] * m
        r[0] = v & 0xFFFF
        for i in range(1, 15):
            v = _trunc16(v) + a[i] * m
            r[i] = v & 0xFFFF
        r15 = _trunc16(v) + a[15] * m
        Curve25519.c255lreduce(r, r15)

    @staticmethod
    def recip(y, x, sqrtassist):
        """y = x^(p-2)  or  x^((p-5)/8) when sqrtassist."""
        t0 = Curve25519.createUnpackedArray()
        t1 = Curve25519.createUnpackedArray()
        t2 = Curve25519.createUnpackedArray()
        t3 = Curve25519.createUnpackedArray()
        t4 = Curve25519.createUnpackedArray()

        Curve25519.sqr(t1, x)           #  2
        Curve25519.sqr(t2, t1)          #  4
        Curve25519.sqr(t0, t2)          #  8
        Curve25519.mul(t2, t0, x)       #  9
        Curve25519.mul(t0, t2, t1)      # 11
        Curve25519.sqr(t1, t0)          # 22
        Curve25519.mul(t3, t1, t2)      # 31 = 2^5 - 2^0
        Curve25519.sqr(t1, t3)          # 2^6  - 2^1
        Curve25519.sqr(t2, t1)          # 2^7  - 2^2
        Curve25519.sqr(t1, t2)          # 2^8  - 2^3
        Curve25519.sqr(t2, t1)          # 2^9  - 2^4
        Curve25519.sqr(t1, t2)          # 2^10 - 2^5
        Curve25519.mul(t2, t1, t3)      # 2^10 - 2^0
        Curve25519.sqr(t1, t2)          # 2^11 - 2^1
        Curve25519.sqr(t3, t1)          # 2^12 - 2^2
        for _ in range(1, 5):
            Curve25519.sqr(t1, t3)
            Curve25519.sqr(t3, t1)
        # t3 = 2^20 - 2^10
        Curve25519.mul(t1, t3, t2)      # 2^20 - 2^0
        Curve25519.sqr(t3, t1)          # 2^21 - 2^1
        Curve25519.sqr(t4, t3)          # 2^22 - 2^2
        for _ in range(1, 10):
            Curve25519.sqr(t3, t4)
            Curve25519.sqr(t4, t3)
        # t4 = 2^40 - 2^20
        Curve25519.mul(t3, t4, t1)      # 2^40 - 2^0
        for _ in range(5):
            Curve25519.sqr(t1, t3)
            Curve25519.sqr(t3, t1)
        # t3 = 2^50 - 2^10
        Curve25519.mul(t1, t3, t2)      # 2^50 - 2^0
        Curve25519.sqr(t2, t1)          # 2^51 - 2^1
        Curve25519.sqr(t3, t2)          # 2^52 - 2^2
        for _ in range(1, 25):
            Curve25519.sqr(t2, t3)
            Curve25519.sqr(t3, t2)
        # t3 = 2^100 - 2^50
        Curve25519.mul(t2, t3, t1)      # 2^100 - 2^0
        Curve25519.sqr(t3, t2)          # 2^101 - 2^1
        Curve25519.sqr(t4, t3)          # 2^102 - 2^2
        for _ in range(1, 50):
            Curve25519.sqr(t3, t4)
            Curve25519.sqr(t4, t3)
        # t4 = 2^200 - 2^100
        Curve25519.mul(t3, t4, t2)      # 2^200 - 2^0
        for _ in range(25):
            Curve25519.sqr(t4, t3)
            Curve25519.sqr(t3, t4)
        # t3 = 2^250 - 2^50
        Curve25519.mul(t2, t3, t1)      # 2^250 - 2^0
        Curve25519.sqr(t1, t2)          # 2^251 - 2^1
        Curve25519.sqr(t2, t1)          # 2^252 - 2^2
        if sqrtassist != 0:
            Curve25519.mul(y, x, t2)    # 2^252 - 3
        else:
            Curve25519.sqr(t1, t2)      # 2^253 - 2^3
            Curve25519.sqr(t2, t1)      # 2^254 - 2^4
            Curve25519.sqr(t1, t2)      # 2^255 - 2^5
            Curve25519.mul(y, t1, t0)   # 2^255 - 21

    @staticmethod
    def is_negative(x):
        isOverflowOrNegative = Curve25519.is_overflow(x) or x[9] < 0
        lsb = x[0] & 1
        return ((1 if isOverflowOrNegative else 0) ^ lsb) & 0xFFFFFFFF

    @staticmethod
    def sqrt(x, u):
        v  = Curve25519.createUnpackedArray()
        t1 = Curve25519.createUnpackedArray()
        t2 = Curve25519.createUnpackedArray()
        Curve25519.add(t1, u, u)            # t1 = 2u
        Curve25519.recip(v, t1, 1)          # v  = (2u)^((p-5)/8)
        Curve25519.sqr(x, v)                # x  = v^2
        Curve25519.mul(t2, t1, x)           # t2 = 2uv^2
        Curve25519.sub(t2, t2, Curve25519.C1)  # t2 = 2uv^2-1
        Curve25519.mul(t1, v, t2)           # t1 = v(2uv^2-1)
        Curve25519.mul(x, u, t1)            # x  = uv(2uv^2-1)

    # ------------------------------------------------------------------
    # Montgomery ladder
    # ------------------------------------------------------------------

    @staticmethod
    def mont_prep(t1, t2, ax, az):
        Curve25519.add(t1, ax, az)
        Curve25519.sub(t2, ax, az)

    @staticmethod
    def mont_add(t1, t2, t3, t4, ax, az, dx):
        Curve25519.mul(ax, t2, t3)
        Curve25519.mul(az, t1, t4)
        Curve25519.add(t1, ax, az)
        Curve25519.sub(t2, ax, az)
        Curve25519.sqr(ax, t1)
        Curve25519.sqr(t1, t2)
        Curve25519.mul(az, t1, dx)

    @staticmethod
    def mont_dbl(t1, t2, t3, t4, bx, bz):
        Curve25519.sqr(t1, t3)
        Curve25519.sqr(t2, t4)
        Curve25519.mul(bx, t1, t2)
        Curve25519.sub(t2, t1, t2)
        Curve25519.mul_small(bz, t2, 121665)
        Curve25519.add(t1, t1, bz)
        Curve25519.mul(bz, t1, t2)

    @staticmethod
    def x_to_y2(t, y2, x):
        Curve25519.sqr(t, x)
        Curve25519.mul_small(y2, x, 486662)
        Curve25519.add(t, t, y2)
        Curve25519.add(t, t, Curve25519.C1)
        Curve25519.mul(y2, t, x)

    @staticmethod
    def core(Px, s, k, Gx):
        """P = kG  and  s = sign(P)/k  (s may be None)."""
        dx = Curve25519.createUnpackedArray()
        t1 = Curve25519.createUnpackedArray()
        t2 = Curve25519.createUnpackedArray()
        t3 = Curve25519.createUnpackedArray()
        t4 = Curve25519.createUnpackedArray()
        x  = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        z  = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]

        if Gx is not None:
            Curve25519.unpack(dx, Gx)
        else:
            Curve25519.set(dx, 9)

        Curve25519.set(x[0], 1)
        Curve25519.set(z[0], 0)
        Curve25519.cpy(x[1], dx)
        Curve25519.set(z[1], 1)

        for i in range(31, -1, -1):
            for j in range(7, -1, -1):
                bit1 = (k[i] & 0xFF) >> j & 1
                bit0 = (~(k[i] & 0xFF)) >> j & 1
                ax = x[bit0]; az = z[bit0]
                bx = x[bit1]; bz = z[bit1]

                Curve25519.mont_prep(t1, t2, ax, az)
                Curve25519.mont_prep(t3, t4, bx, bz)
                Curve25519.mont_add(t1, t2, t3, t4, ax, az, dx)
                Curve25519.mont_dbl(t1, t2, t3, t4, bx, bz)

        Curve25519.recip(t1, z[0], 0)
        Curve25519.mul(dx, x[0], t1)
        Curve25519.pack(dx, Px)

        if s is not None:
            Curve25519.x_to_y2(t2, t1, dx)          # t1 = Py^2
            Curve25519.recip(t3, z[1], 0)
            Curve25519.mul(t2, x[1], t3)             # t2 = Qx
            Curve25519.add(t2, t2, dx)               # t2 = Qx + Px
            Curve25519.add(t2, t2, Curve25519.C486671)   # t2 = Qx + Px + Gx + 486662
            Curve25519.sub(dx, dx, Curve25519.C9)         # dx = Px - Gx
            Curve25519.sqr(t3, dx)                    # t3 = (Px - Gx)^2
            Curve25519.mul(dx, t2, t3)                # dx = t2*(Px - Gx)^2
            Curve25519.sub(dx, dx, t1)                # dx -= Py^2
            Curve25519.sub(dx, dx, Curve25519.C39420360)  # dx -= Gy^2
            Curve25519.mul(t1, dx, Curve25519.BASE_R2Y)   # t1 = -Py

            if Curve25519.is_negative(t1) != 0:
                Curve25519.cpy32(s, k)
            else:
                Curve25519.mula_small(s, Curve25519.ORDER_TIMES_8, 0, k, 32, -1)

            temp1 = [0] * 32
            temp2 = [0] * 64
            temp3 = [0] * 64
            Curve25519.cpy32(temp1, Curve25519.ORDER)
            result = Curve25519.egcd32(temp2, temp3, s, temp1)
            Curve25519.cpy32(s, result)
            if (s[31] & 0x80) != 0:
                Curve25519.mula_small(s, s, 0, Curve25519.ORDER, 32, 1)


# ---------------------------------------------------------------------------
# ECKCDSA
# ---------------------------------------------------------------------------

class ECKCDSA:

    @staticmethod
    def sign(h, x, s):
        """v = (x - h) * s mod q.  Returns 32-byte list or None."""
        h1 = [0] * 32
        x1 = [0] * 32
        tmp1 = [0] * 64
        tmp2 = [0] * 64

        Curve25519.cpy32(h1, h)
        Curve25519.cpy32(x1, x)

        tmp3 = [0] * 32
        Curve25519.divmod(tmp3, h1, 32, Curve25519.ORDER, 32)
        Curve25519.divmod(tmp3, x1, 32, Curve25519.ORDER, 32)

        v = [0] * 32
        Curve25519.mula_small(v, x1, 0, h1, 32, -1)
        Curve25519.mula_small(v, v,  0, Curve25519.ORDER, 32, 1)

        Curve25519.mula32(tmp1, v, s, 32, 1)
        Curve25519.divmod(tmp2, tmp1, 64, Curve25519.ORDER, 32)

        w = 0
        for i in range(32):
            v[i] = tmp1[i]
            w |= v[i]

        return v if w != 0 else None

    @staticmethod
    def verify(v, h, P):
        """Y = v*P + h*G — returns Y as 32-byte list."""
        d = [0] * 32
        p  = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        s  = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        yx = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        yz = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        t1 = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]
        t2 = [Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray(), Curve25519.createUnpackedArray()]

        vi = 0; hi = 0; di = 0; nvh = 0

        Curve25519.set(p[0], 9)
        Curve25519.unpack(p[1], P)

        Curve25519.x_to_y2(t1[0], t2[0], p[1])           # t2[0] = Py^2
        Curve25519.sqrt(t1[0], t2[0])                     # t1[0] = Py or -Py
        j = Curve25519.is_negative(t1[0])
        Curve25519.add(t2[0], t2[0], Curve25519.C39420360)   # t2[0] = Py^2 + Gy^2
        Curve25519.mul(t2[1], Curve25519.BASE_2Y, t1[0])     # t2[1] = ±2 Py Gy
        Curve25519.sub(t1[j],     t2[0], t2[1])
        Curve25519.add(t1[1 - j], t2[0], t2[1])
        Curve25519.cpy(t2[0], p[1])
        Curve25519.sub(t2[0], t2[0], Curve25519.C9)
        Curve25519.sqr(t2[1], t2[0])
        Curve25519.recip(t2[0], t2[1], 0)
        Curve25519.mul(s[0], t1[0], t2[0])
        Curve25519.sub(s[0], s[0], p[1])
        Curve25519.sub(s[0], s[0], Curve25519.C486671)
        Curve25519.mul(s[1], t1[1], t2[0])
        Curve25519.sub(s[1], s[1], p[1])
        Curve25519.sub(s[1], s[1], Curve25519.C486671)
        Curve25519.mul_small(s[0], s[0], 1)
        Curve25519.mul_small(s[1], s[1], 1)

        # Build chain
        for i in range(32):
            vi = (vi >> 8) ^ (v[i] & 0xFF) ^ ((v[i] & 0xFF) << 1)
            hi = (hi >> 8) ^ (h[i] & 0xFF) ^ ((h[i] & 0xFF) << 1)
            nvh = ~(vi ^ hi)
            di = (nvh & (di & 0x80) >> 7) ^ vi
            di ^= nvh & (di & 0x01) << 1
            di ^= nvh & (di & 0x02) << 1
            di ^= nvh & (di & 0x04) << 1
            di ^= nvh & (di & 0x08) << 1
            di ^= nvh & (di & 0x10) << 1
            di ^= nvh & (di & 0x20) << 1
            di ^= nvh & (di & 0x40) << 1
            d[i] = di & 0xFF

        di = ((nvh & (di & 0x80) << 1) ^ vi) >> 8

        Curve25519.set(yx[0], 1)
        Curve25519.cpy(yx[1], p[di])
        Curve25519.cpy(yx[2], s[0])
        Curve25519.set(yz[0], 0)
        Curve25519.set(yz[1], 1)
        Curve25519.set(yz[2], 1)

        vi = 0; hi = 0

        for i in range(31, -1, -1):
            # JS uses int32 arithmetic: << 8 truncates to 32 bits
            vi = _to_int32((vi << 8) | (v[i] & 0xFF))
            hi = _to_int32((hi << 8) | (h[i] & 0xFF))
            di = _to_int32((di << 8) | (d[i] & 0xFF))

            for j in range(7, -1, -1):
                Curve25519.mont_prep(t1[0], t2[0], yx[0], yz[0])
                Curve25519.mont_prep(t1[1], t2[1], yx[1], yz[1])
                Curve25519.mont_prep(t1[2], t2[2], yx[2], yz[2])

                k = ((_sar32(vi ^ _sar32(vi, 1), j) & 1)
                     + (_sar32(hi ^ _sar32(hi, 1), j) & 1))
                Curve25519.mont_dbl(yx[2], yz[2], t1[k], t2[k], yx[0], yz[0])

                k = (_sar32(di, j) & 2) ^ ((_sar32(di, j) & 1) << 1)
                Curve25519.mont_add(t1[1], t2[1], t1[k], t2[k], yx[1], yz[1],
                                    p[_sar32(di, j) & 1])

                Curve25519.mont_add(t1[2], t2[2], t1[0], t2[0], yx[2], yz[2],
                                    s[(_sar32(vi ^ hi, j) & 2) >> 1])

        k = (vi & 1) + (hi & 1)
        Curve25519.recip(t1[0], yz[k], 0)
        Curve25519.mul(t1[1], yx[k], t1[0])

        Y = []
        Curve25519.pack(t1[1], Y)
        return Y

    @staticmethod
    def keygen(k):
        """k is a list of 32 ints (bytes).  Returns {p, s, k}."""
        P = [0] * 32
        s = [0] * 32
        Curve25519.clamp(k)
        Curve25519.core(P, s, k, None)
        return {"p": P, "s": s, "k": k}


# ---------------------------------------------------------------------------
# High-level API
# ---------------------------------------------------------------------------

def generate_sign_keys(passphrase: str) -> dict:
    """
    Derive Signum sign keys from a passphrase.

    Returns dict with keys:
      publicKey         — hex string (32 bytes)
      signPrivateKey    — hex string (32 bytes)
      agreementPrivateKey — hex string (32 bytes)
    """
    hashed = list(_sha256(passphrase.encode("utf-8")))
    keys = ECKCDSA.keygen(hashed)
    return {
        "publicKey":          bytes(keys["p"]).hex(),
        "signPrivateKey":     bytes(keys["s"]).hex(),
        "agreementPrivateKey": bytes(keys["k"]).hex(),
    }


def generate_signature(message_hex: str, private_key_hex: str) -> str:
    """
    Sign a message (as hex) with the sign private key (as hex).

    Returns 64-byte signature as hex string.
    """
    m_bytes = bytes.fromhex(message_hex)
    m = list(_sha256(m_bytes))             # sha256 of raw message bytes

    s = list(bytes.fromhex(private_key_hex))

    x_bytes = _sha256(bytes(m) + bytes(s))
    x = list(x_bytes)

    # keygen clamps x in-place in JS; pass x directly so our copy is clamped too
    y_keys = ECKCDSA.keygen(x)            # clamps x in-place
    y = list(bytes(y_keys["p"]))

    h = list(_sha256(bytes(m) + bytes(y)))

    # x is now clamped (same as JS reference)
    v = ECKCDSA.sign(list(h), list(x), list(s))
    if v is None:
        raise ValueError("Signature generation failed — v is zero")

    return bytes(v).hex() + bytes(h).hex()


def verify_signature(signature_hex: str, message_hex: str, public_key_hex: str) -> bool:
    """
    Verify a Signum EC-KCDSA signature.

    signature_hex   — 128 hex chars (v||h, each 32 bytes)
    message_hex     — original message as hex
    public_key_hex  — public key as hex (32 bytes)

    Returns True if valid.
    """
    if len(signature_hex) != 128:
        return False

    v = list(bytes.fromhex(signature_hex[:64]))
    h = list(bytes.fromhex(signature_hex[64:]))
    P = list(bytes.fromhex(public_key_hex))

    m_bytes = bytes.fromhex(message_hex)
    m = list(_sha256(m_bytes))

    Y = ECKCDSA.verify(v, h, P)

    h2 = list(_sha256(bytes(m) + bytes(Y)))
    return h2 == h


def generate_signed_transaction_bytes(unsigned_hex: str, signature: str) -> str:
    """
    Embed a 64-byte signature into unsigned transaction bytes.

    The signature occupies bytes 64..128 (chars 128..256) of the transaction,
    i.e. chars 192..320 in the hex representation (96..160 byte range).

    Formula:  unsignedHex[:192] + signature + unsignedHex[320:]
    """
    return unsigned_hex[:192] + signature + unsigned_hex[320:]


# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import sys

    PASSPHRASE   = "hope green dust squeeze bright catch crush tuna lend squeeze"
    MESSAGE_HEX  = "00" * 64   # 64 zero bytes — stand-in for an unsigned tx

    print("=== Signum Crypto Self-Test ===")
    print(f"Passphrase : {PASSPHRASE!r}")

    try:
        # Key generation
        keys = generate_sign_keys(PASSPHRASE)
        print(f"Public key : {keys['publicKey']}")
        print(f"Sign priv  : {keys['signPrivateKey']}")
        print(f"Agree priv : {keys['agreementPrivateKey']}")

        # Signing
        sig = generate_signature(MESSAGE_HEX, keys["signPrivateKey"])
        print(f"Signature  : {sig}")

        # Verification
        ok = verify_signature(sig, MESSAGE_HEX, keys["publicKey"])
        print(f"Verify     : {'PASS' if ok else 'FAIL'}")

        # Rejection test — flip one bit in signature
        bad_sig = (format(int(sig[:2], 16) ^ 1, "02x") + sig[2:])
        ok2 = verify_signature(bad_sig, MESSAGE_HEX, keys["publicKey"])
        print(f"Reject bad : {'PASS' if not ok2 else 'FAIL'}")

        if ok and not ok2:
            print("\nSELF-TEST: PASS")
            sys.exit(0)
        else:
            print("\nSELF-TEST: FAIL")
            sys.exit(1)

    except Exception as e:
        import traceback
        traceback.print_exc()
        print(f"\nSELF-TEST: FAIL ({e})")
        sys.exit(1)


path: scripts/verify.py
content: #!/usr/bin/env python3
"""
Signum Verifiable Outputs — hash content, publish proof on-chain, verify later.

How it works:
  1. BEFORE delivering: hash your output + sources → get a fingerprint
  2. PUBLISH: send the hash to Signum blockchain → gets a timestamp + TX ID
  3. DELIVER: give the recipient your output AND the TX ID
  4. VERIFY: anyone can check the output hash matches the on-chain record

This is the lightweight alternative to zkML:
  - No special hardware, no ZK circuits
  - Costs ~$0.000007 per proof (at current SIGNA price)
  - Immutable, timestamped, publicly auditable
  - Proves what was said, when, and that it hasn't been altered

Usage:
  python3 verify.py hash <content> [--sources "url1,url2"]
  python3 verify.py publish <passphrase> <content_hash> [--label "task description"]
  python3 verify.py verify <content> <tx_id> [--sources "url1,url2"]
  python3 verify.py proofs <address> [--limit 20]
"""
import sys
import os
import json
import hashlib
sys.path.insert(0, os.path.dirname(__file__))
from signum_api import get_api, ts, fee_message, ok, EXPLORER_URL
from wallet import get_my_address
from protocol import build_sigproof, parse_sigproof
from protocol import PROOF_PREFIX


def hash_content(content, sources=None):
    """
    Generate a SHA-256 fingerprint of content + sources.

    content: the text/data being proven (string or bytes)
    sources: list of source URLs or identifiers used to produce content

    Returns:
      content_hash: SHA-256 of content only
      sources_hash: SHA-256 of sorted sources (reproducible)
      combined_hash: SHA-256 of content + sources together

    Canonical artifact: raw UTF-8 bytes of the content string, no normalization.
    Content must be identical byte-for-byte at stamp time and verify time.
    """
    if isinstance(content, str):
        content = content.encode('utf-8')

    content_hash = hashlib.sha256(content).hexdigest()

    sources = sorted(sources or [])
    sources_str = "|".join(sources).encode('utf-8')
    sources_hash = hashlib.sha256(sources_str).hexdigest()

    combined = hashlib.sha256(content + sources_str).hexdigest()

    return {
        "content_hash": content_hash,
        "sources_hash": sources_hash,
        "combined_hash": combined,
        "sources": sources,
    }


def publish_proof(passphrase, content_hash, sources_hash="", label="", network=None):
    """
    Publish a content hash to Signum blockchain.
    Returns tx_id — this is your proof receipt.

    The on-chain record contains:
      - The hash (immutable)
      - A timestamp (from block time)
      - The publishing agent's address
      - An optional label
    """
    api = get_api(network)
    address, err = get_my_address(passphrase, network)
    if err:
        return None, err

    # Build proof message
    message = build_sigproof(content_hash, sources_hash, label)

    result = api.post("sendMessage",
                      secretPhrase=passphrase,
                      recipient=address,  # self-message = public proof record
                      message=message,
                      messageIsText="true",
                      feeNQT=fee_message(message))

    if not ok(result):
        return None, result.get("error", "Failed to publish proof")

    return {
        "tx_id": result.get("transaction"),
        "address": address,
        "content_hash": content_hash,
        "sources_hash": sources_hash,
        "label": label,
    }, None


def verify_proof(content, tx_id, sources=None, network=None):
    """
    Verify that content matches an on-chain proof.

    Steps:
      1. Hash the provided content + sources
      2. Look up the on-chain transaction
      3. Compare the hashes

    Returns:
      verified: True/False
      details: dict with match info and on-chain timestamp
    """
    api = get_api(network)

    # Hash the provided content
    hashes = hash_content(content, sources)

    # Fetch the on-chain transaction
    tx = api.get("getTransaction", transaction=tx_id)
    if not ok(tx):
        return False, {"error": f"Transaction not found: {tx_id}"}

    # Extract the proof from the message
    msg = tx.get("attachment", {}).get("message", "")
    if not msg.startswith(PROOF_PREFIX):
        return False, {"error": "Transaction does not contain a SIGPROOF record"}

    try:
        proof = parse_sigproof(msg)
    except Exception:
        return False, {"error": "Malformed proof record"}

    onchain_content_hash = proof.content_hash
    onchain_sources_hash = proof.sources_hash

    content_match = hashes["content_hash"] == onchain_content_hash
    sources_match = (not onchain_sources_hash or
                     hashes["sources_hash"] == onchain_sources_hash)

    verified = content_match and sources_match

    return verified, {
        "verified": verified,
        "content_match": content_match,
        "sources_match": sources_match,
        "timestamp": ts(tx.get("timestamp")),
        "publisher": tx.get("senderRS", tx.get("sender")),
        "block": tx.get("block"),
        "onchain_content_hash": onchain_content_hash,
        "computed_content_hash": hashes["content_hash"],
        "label": proof.label,
    }


def get_proofs(address, limit=20, network=None):
    """Get all proof records published by an address."""
    api = get_api(network)

    result = api.get("getAccountTransactions",
                     account=address,
                     numberOfTransactions=limit,
                     type=1)  # messaging transactions

    proofs = []
    for tx in (result.get("transactions") or []):
        msg = tx.get("attachment", {}).get("message", "")
        if msg.startswith(PROOF_PREFIX):
            try:
                proof = parse_sigproof(msg)
            except Exception:
                continue
            proofs.append({
                "tx_id": tx.get("transaction"),
                "timestamp": ts(tx.get("timestamp")),
                "content_hash": proof.content_hash,
                "label": proof.label,
                "confirmations": tx.get("confirmations", 0),
            })

    return proofs


# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
    import argparse
    parser = argparse.ArgumentParser(description="Signum Verifiable Outputs")
    parser.add_argument("--network", default=os.environ.get("SIGNUM_NETWORK", "testnet"),
                        choices=["mainnet", "testnet"])
    sub = parser.add_subparsers(dest="cmd")

    p = sub.add_parser("hash", help="Hash content (no blockchain call)")
    p.add_argument("content")
    p.add_argument("--sources", default="", help="Comma-separated source URLs")

    p = sub.add_parser("publish", help="Publish a proof on-chain")
    p.add_argument("passphrase")
    p.add_argument("content_hash")
    p.add_argument("--sources-hash", default="")
    p.add_argument("--label", default="")

    p = sub.add_parser("verify", help="Verify content against an on-chain proof")
    p.add_argument("content")
    p.add_argument("tx_id")
    p.add_argument("--sources", default="")

    p = sub.add_parser("proofs", help="List proofs published by an address")
    p.add_argument("address")
    p.add_argument("--limit", type=int, default=20)

    # Convenience: hash + publish in one step
    p = sub.add_parser("stamp", help="Hash content and publish proof in one step")
    p.add_argument("passphrase")
    p.add_argument("content")
    p.add_argument("--sources", default="")
    p.add_argument("--label", default="")

    args = parser.parse_args()
    os.environ["SIGNUM_NETWORK"] = args.network

    if args.cmd == "hash":
        sources = [s.strip() for s in args.sources.split(",") if s.strip()]
        h = hash_content(args.content, sources)
        print(f"Content hash:  {h['content_hash']}")
        print(f"Sources hash:  {h['sources_hash']}")
        print(f"Combined hash: {h['combined_hash']}")
        if sources:
            print(f"Sources ({len(sources)}): {', '.join(sources)}")

    elif args.cmd == "publish":
        result, err = publish_proof(args.passphrase, args.content_hash,
                                    args.sources_hash, args.label, args.network)
        if err:
            print(f"Error: {err}")
        else:
            print(f"✓ Proof published on-chain")
            print(f"  TX ID:   {result['tx_id']}")
            print(f"  Hash:    {result['content_hash']}")
            print(f"  Address: {result['address']}")
            print(f"  View:    {EXPLORER_URL}/tx/{result['tx_id']}")

    elif args.cmd == "stamp":
        sources = [s.strip() for s in args.sources.split(",") if s.strip()]
        h = hash_content(args.content, sources)
        print(f"Content hash: {h['content_hash']}")
        result, err = publish_proof(args.passphrase, h['content_hash'],
                                    h['sources_hash'], args.label, args.network)
        if err:
            print(f"Error publishing: {err}")
        else:
            print(f"✓ Proof stamped on-chain")
            print(f"  TX ID: {result['tx_id']}")
            print(f"  Give the recipient this TX ID to verify your output.")
            print(f"  View:  {EXPLORER_URL}/tx/{result['tx_id']}")

    elif args.cmd == "verify":
        sources = [s.strip() for s in args.sources.split(",") if s.strip()]
        verified, details = verify_proof(args.content, args.tx_id, sources, args.network)
        if "error" in details:
            print(f"Error: {details['error']}")
        elif verified:
            print(f"✓ VERIFIED — content matches on-chain proof")
            print(f"  Timestamp:  {details['timestamp']}")
            print(f"  Publisher:  {details['publisher']}")
            print(f"  Block:      {details['block']}")
        else:
            print(f"✗ VERIFICATION FAILED")
            print(f"  Content match: {details['content_match']}")
            print(f"  Sources match: {details['sources_match']}")
            print(f"  On-chain hash: {details['onchain_content_hash']}")
            print(f"  Computed hash: {details['computed_content_hash']}")

    elif args.cmd == "proofs":
        proofs = get_proofs(args.address, args.limit, args.network)
        if not proofs:
            print("No proofs found.")
        else:
            print(f"{'TIMESTAMP':<18} {'TX ID':<20} {'HASH':<16} {'LABEL'}")
            print("-" * 75)
            for p in proofs:
                print(f"{p['timestamp']:<18} {p['tx_id']:<20} {p['content_hash'][:14]}...  {p['label']}")
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


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