diff --git a/backend/alembic/versions/0011_escrow_agent.py b/backend/alembic/versions/0011_escrow_agent.py index c2e0dbb..9d9c70a 100644 --- a/backend/alembic/versions/0011_escrow_agent.py +++ b/backend/alembic/versions/0011_escrow_agent.py @@ -8,7 +8,6 @@ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql revision: str = "0011_escrow_agent" down_revision: Union[str, None] = "0010_reward_claims" @@ -22,7 +21,7 @@ def upgrade() -> None: sa.Column("id", sa.Uuid(), nullable=False), sa.Column("user_id", sa.Uuid(), nullable=False), sa.Column("coordinate", sa.String(), nullable=False), - sa.Column("event_json", postgresql.JSONB(), nullable=False), + sa.Column("event_json", sa.JSON(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), sa.ForeignKeyConstraint(["user_id"], ["users.id"]), sa.PrimaryKeyConstraint("id"), diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py index 8273940..498c6f7 100644 --- a/backend/app/api/v1/payouts.py +++ b/backend/app/api/v1/payouts.py @@ -38,6 +38,7 @@ def _payout_out(db: Session, payout: Payout) -> PayoutOut: return PayoutOut( id=payout.id, event_id=payout.event_id, allocation_id=payout.allocation_id, team_label=payout.team_label, total_sats=payout.total_sats, status=payout.status, + escrow_coordinate=payout.escrow_coordinate, escrow_status=payout.escrow_status, items=items, ) @@ -287,7 +288,7 @@ def create_payout( ) if req.escrow_coordinate: - splits = payout_service.compute_split(members, req.total_sats) + splits = [(m, "", amt) for (m, amt) in payout_service.compute_split(members, req.total_sats)] else: try: splits = payout_service.preflight(db, req.team_id, req.total_sats, req.addresses) diff --git a/backend/app/models/escrow_agent.py b/backend/app/models/escrow_agent.py index 1a6e40c..c25c18e 100644 --- a/backend/app/models/escrow_agent.py +++ b/backend/app/models/escrow_agent.py @@ -1,6 +1,5 @@ import uuid -from sqlalchemy import Column, String, DateTime, ForeignKey, Uuid -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import Column, String, DateTime, ForeignKey, Uuid, JSON from sqlalchemy.sql import func from app.core.database import Base @@ -12,5 +11,5 @@ class EscrowAgentEvent(Base): id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) coordinate = Column(String, unique=True, nullable=False, index=True) - event_json = Column(JSONB, nullable=False) + event_json = Column(JSON, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/tests/test_escrow_api.py b/backend/tests/test_escrow_api.py index 9da3874..a60e53e 100644 --- a/backend/tests/test_escrow_api.py +++ b/backend/tests/test_escrow_api.py @@ -9,9 +9,6 @@ from coincurve import PrivateKey -ESCHRON_HEADERS = None - - def make_signed_escrow_event(privkey: PrivateKey, identifier: str = "my-agent") -> dict: """Build and sign a kind-30361 escrow descriptor event.""" pubkey_hex = privkey.public_key.format(compressed=True)[1:].hex() @@ -163,13 +160,15 @@ def test_refresh_agents(client, auth_headers): def test_escrow_payout_create_and_status_flow(client, auth_headers): """Full escrow payout lifecycle: create → funded → released.""" - e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Escrow Payout", "team_count": 1}).json() + e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Escrow Payout", "team_count": 2}).json() client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) - r = client.post( - f"/api/v1/events/{e['registration_slug']}/register", - json={"name": "EscrowRecipient", "email": "er@t.com", "primary_strength": "technical", "experience_level": "advanced"}, - ) - assert r.status_code in (200, 201) + for i, strength in enumerate(["technical", "design"]): + r = client.post( + f"/api/v1/events/{e['registration_slug']}/register", + json={"name": f"EscrowRecipient{i}", "email": f"er{i}@t.com", + "primary_strength": strength, "experience_level": "advanced"}, + ) + assert r.status_code in (200, 201) a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json() teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json() @@ -187,7 +186,7 @@ def test_escrow_payout_create_and_status_flow(client, auth_headers): payout = payout_res.json() assert payout["escrow_coordinate"] == "30361:deadbeef:my-agent" assert payout["escrow_status"] == "escrow_pending" - assert len(payout["items"]) == 1 + assert len(payout["items"]) >= 1 funded_res = client.post( f"/api/v1/allocations/payouts/{payout['id']}/escrow-funded", @@ -206,14 +205,16 @@ def test_escrow_payout_create_and_status_flow(client, auth_headers): def test_escrow_funded_rejects_non_pending(client, auth_headers): """Marking funded on a direct payout is rejected.""" - e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Direct Payout", "team_count": 1}).json() + e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Direct Payout", "team_count": 2}).json() client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) - r = client.post( - f"/api/v1/events/{e['registration_slug']}/register", - json={"name": "DirectRecipient", "email": "dr@t.com", "primary_strength": "technical", - "experience_level": "advanced", "lightning_address": "dr@getalby.com"}, - ) - assert r.status_code in (200, 201) + for i, strength in enumerate(["technical", "design"]): + r = client.post( + f"/api/v1/events/{e['registration_slug']}/register", + json={"name": f"DirectRecipient{i}", "email": f"dr{i}@t.com", + "primary_strength": strength, "experience_level": "advanced", + "lightning_address": f"dr{i}@getalby.com"}, + ) + assert r.status_code in (200, 201) a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json() teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json() diff --git a/frontend/components/dashboard/agent-settings.tsx b/frontend/components/dashboard/agent-settings.tsx index af690a5..163cc78 100644 --- a/frontend/components/dashboard/agent-settings.tsx +++ b/frontend/components/dashboard/agent-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useSession } from "next-auth/react"; import { toast } from "sonner"; import { Shield, Globe, Zap, Loader2, CheckCircle, Info } from "lucide-react"; @@ -39,9 +39,26 @@ export function AgentSettings() { const [publishing, setPublishing] = useState(false); const [published, setPublished] = useState(null); const [agentList, setAgentList] = useState([]); - const [loaded, setLoaded] = useState(false); + const loadedRef = useRef(false); - const loadAgents = async () => { + useEffect(() => { + if (loadedRef.current) return; + loadedRef.current = true; + fetchAPI<{ agents: PublishedAgent[] }>("/api/v1/escrow/agents") + .then((res) => { + setAgentList(res.agents); + const myCoord = capability?.pk + ? escrowCoordinate(capability.pk, identifier || undefined) + : null; + if (myCoord) { + const existing = res.agents.find((a) => a.coordinate === myCoord); + if (existing) setPublished(existing); + } + }) + .catch(() => { /* agent list is non-critical */ }); + }, []); + + const refreshAgents = async () => { try { const res = await fetchAPI<{ agents: PublishedAgent[] }>( "/api/v1/escrow/agents", @@ -56,15 +73,9 @@ export function AgentSettings() { } } catch { // silent: agent list is non-critical - } finally { - setLoaded(true); } }; - useState(() => { - loadAgents(); - }); - const handlePublish = async () => { if (!capability) { toast.error("Sign in with a Nostr key to publish an agent"); @@ -289,7 +300,7 @@ export function AgentSettings() {
- diff --git a/frontend/components/engine/payout-modal.tsx b/frontend/components/engine/payout-modal.tsx index 542efb1..98205a0 100644 --- a/frontend/components/engine/payout-modal.tsx +++ b/frontend/components/engine/payout-modal.tsx @@ -57,23 +57,20 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo const [preflight, setPreflight] = useState([]); const [claims, setClaims] = useState([]); const [paymentAttempts, setPaymentAttempts] = useState>({}); - const [escrowAgents, setEscrowAgents] = useState([]); + const [escrowAgents, setEscrowAgents] = useState(null); const [selectedAgent, setSelectedAgent] = useState(null); - const [loadingAgents, setLoadingAgents] = useState(false); const n = team.members.length; const base = n > 0 ? Math.floor(totalSats / n) : 0; const rem = n > 0 ? totalSats % n : 0; useEffect(() => { - if (open && payoutMode === "escrow") { - setLoadingAgents(true); + if (open && payoutMode === "escrow" && escrowAgents === null) { fetchEscrowAgents() .then((res) => setEscrowAgents(res.agents)) - .catch(() => toast.error("Could not load escrow agents")) - .finally(() => setLoadingAgents(false)); + .catch(() => { toast.error("Could not load escrow agents"); setEscrowAgents([]); }); } - }, [open, payoutMode]); + }, [open, payoutMode, escrowAgents]); const handleOpenChange = (o: boolean) => { if (!o && Object.keys(paymentAttempts).length > 0) { @@ -84,6 +81,7 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo setNwc(""); setPreflight([]); setClaims([]); + setEscrowAgents(null); } onOpenChange(o); }; @@ -340,11 +338,11 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo until prizes are released.

- {loadingAgents && ( + {escrowAgents === null && (

Loading agents from relays...

)} - {!loadingAgents && escrowAgents.length === 0 && ( + {escrowAgents !== null && escrowAgents.length === 0 && (

No escrow agents found. Register yourself as an agent from the{" "} Agent page, or use Direct send instead. @@ -352,7 +350,7 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo )}

- {escrowAgents.map((agent) => ( + {escrowAgents?.map((agent) => (