Skip to content
3 changes: 1 addition & 2 deletions backend/alembic/versions/0011_escrow_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/v1/payouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions backend/app/models/escrow_agent.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())
35 changes: 18 additions & 17 deletions backend/tests/test_escrow_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand All @@ -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()

Expand Down
31 changes: 21 additions & 10 deletions frontend/components/dashboard/agent-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,9 +39,26 @@
const [publishing, setPublishing] = useState(false);
const [published, setPublished] = useState<PublishedAgent | null>(null);
const [agentList, setAgentList] = useState<PublishedAgent[]>([]);
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 */ });
}, []);

Check warning on line 59 in frontend/components/dashboard/agent-settings.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node)

React Hook useEffect has missing dependencies: 'capability.pk' and 'identifier'. Either include them or remove the dependency array

const refreshAgents = async () => {
try {
const res = await fetchAPI<{ agents: PublishedAgent[] }>(
"/api/v1/escrow/agents",
Expand All @@ -56,15 +73,9 @@
}
} 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");
Expand Down Expand Up @@ -289,7 +300,7 @@
</Card>

<div className="flex gap-2">
<Button onClick={loadAgents} variant="outline" className="gap-2">
<Button onClick={refreshAgents} variant="outline" className="gap-2">
<Globe className="h-4 w-4" />
Refresh agents
</Button>
Expand Down
18 changes: 8 additions & 10 deletions frontend/components/engine/payout-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,20 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo
const [preflight, setPreflight] = useState<string[]>([]);
const [claims, setClaims] = useState<RewardClaim[]>([]);
const [paymentAttempts, setPaymentAttempts] = useState<Record<string, PaymentAttempt>>({});
const [escrowAgents, setEscrowAgents] = useState<EscrowAgent[]>([]);
const [escrowAgents, setEscrowAgents] = useState<EscrowAgent[] | null>(null);
const [selectedAgent, setSelectedAgent] = useState<EscrowAgent | null>(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) {
Expand All @@ -84,6 +81,7 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo
setNwc("");
setPreflight([]);
setClaims([]);
setEscrowAgents(null);
}
onOpenChange(o);
};
Expand Down Expand Up @@ -340,19 +338,19 @@ export function PayoutModal({ team, allocationId, open, onOpenChange }: PayoutMo
until prizes are released.
</p>

{loadingAgents && (
{escrowAgents === null && (
<p className="text-xs text-muted-foreground">Loading agents from relays...</p>
)}

{!loadingAgents && escrowAgents.length === 0 && (
{escrowAgents !== null && escrowAgents.length === 0 && (
<p className="text-xs text-amber-400">
No escrow agents found. Register yourself as an agent from the{" "}
<strong>Agent</strong> page, or use Direct send instead.
</p>
)}

<div className="space-y-2 max-h-48 overflow-y-auto">
{escrowAgents.map((agent) => (
{escrowAgents?.map((agent) => (
<button
key={agent.coordinate}
type="button"
Expand Down
2 changes: 1 addition & 1 deletion frontend/hooks/use-allocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export async function preflightPayout(
export async function createPayout(
token: string,
allocationId: string,
body: { team_id: string; total_sats: number; addresses?: Record<string, string> }
body: { team_id: string; total_sats: number; addresses?: Record<string, string>; escrow_coordinate?: string }
) {
// Self-custody: no nwc is sent. The server returns pending items for the browser to pay.
return fetchAPI<Payout>(`/api/v1/allocations/${allocationId}/payouts`, { method: "POST", body, token });
Expand Down
18 changes: 2 additions & 16 deletions frontend/hooks/use-nostr-identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
Expand Down Expand Up @@ -66,12 +65,6 @@ function hexToBytes(hex: string): Uint8Array {
return new Uint8Array(hex.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []);
}

function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

async function signWithNsec(
skHex: string,
template: EventTemplate,
Expand All @@ -95,14 +88,7 @@ export function NostrIdentityProvider({
children: ReactNode;
}) {
const [capability, setCapabilityState] =
useState<SigningCapability>(null);

const [hydrated, setHydrated] = useState(false);

useEffect(() => {
setCapabilityState(loadFromSession());
setHydrated(true);
}, []);
useState<SigningCapability>(() => loadFromSession());

const setCapability = useCallback((c: SigningCapability) => {
persistToSession(c);
Expand All @@ -125,7 +111,7 @@ export function NostrIdentityProvider({
[capability],
);

if (!hydrated) {
if (typeof window === "undefined") {
return (
<NostrIdentityContext.Provider
value={{ capability: null, setCapability: () => {}, signEvent: async () => { throw new Error("not ready"); }, clearCapability: () => {} }}
Expand Down
Loading