task-29: Timmy as economic peer — Nostr identity, zap-out, vouching, engagement
1. TimmyIdentityService (artifacts/api-server/src/lib/timmy-identity.ts) - Loads nsec from TIMMY_NOSTR_NSEC env var at boot (bech32 decode) - Generates and warns about ephemeral key if env var absent - sign(EventTemplate) → finalizeEvent() with Timmy's key - encryptDm(recipientPubkeyHex, plaintext) → NIP-04 nip04.encrypt() - Logs npub at server startup 2. ZapService (artifacts/api-server/src/lib/zap.ts) - Constructs NIP-57 zap request event (kind 9734), signs with Timmy's key - Pays via lnbitsService.payInvoice() if bolt11 provided (stub-mode aware) - Logs every outbound event to timmy_nostr_events audit table - maybeZapOnJobComplete() wired in jobs.ts after trustService.recordSuccess() - Config: ZAP_PCT_DEFAULT (default 0 = disabled), ZAP_MIN_SATS (default 10) - Only fires for trusted/elite tier partners when ZAP_PCT_DEFAULT > 0 3. Engagement engine (artifacts/api-server/src/lib/engagement.ts) - Configurable cadence: ENGAGEMENT_INTERVAL_DAYS (default 0 = disabled) - Queries nostrIdentities for trustScore >= 50 AND lastSeen < threshold - Generates personalised DM via agentService.chatReply() - Encrypts as NIP-04 DM (kind 4), signs with Timmy's key - Logs to timmy_nostr_events; publishes to NOSTR_RELAY_URL if set - First run delayed 60s after startup to avoid cold-start noise 4. Vouching endpoint (artifacts/api-server/src/routes/identity.ts) - POST /api/identity/vouch: requires X-Nostr-Token with elite tier - Verifies optional Nostr event signature from voucher - Records relationship in nostr_trust_vouches table - Applies VOUCH_TRUST_BOOST (20 pts) to vouchee's trust score - GET /api/identity/timmy: public endpoint returning npub + zap count 5. DB schema additions (lib/db/src/schema/) - timmy_nostr_events: audit log for all outbound Nostr events - nostr_trust_vouches: voucher/vouchee social graph with boost amount - Tables created in production DB via drizzle-kit push 6. Frontend identity card (the-matrix/) - #timmy-id-card: fixed bottom-right widget with Timmy's npub + zap count - timmy-id.js: initTimmyId() fetches /api/identity/timmy on load - Npub shortened (npub1xxxx...yyyyyy), click-to-copy with feedback - Refreshes every 60s to show live zap count - Wired into main.js on firstInit
This commit is contained in:
@@ -2,6 +2,8 @@ import { createServer } from "http";
|
||||
import app from "./app.js";
|
||||
import { attachWebSocketServer } from "./routes/events.js";
|
||||
import { rootLogger } from "./lib/logger.js";
|
||||
import { timmyIdentityService } from "./lib/timmy-identity.js";
|
||||
import { startEngagementEngine } from "./lib/engagement.js";
|
||||
|
||||
const rawPort = process.env["PORT"];
|
||||
|
||||
@@ -20,10 +22,12 @@ attachWebSocketServer(server);
|
||||
|
||||
server.listen(port, () => {
|
||||
rootLogger.info("server started", { port });
|
||||
rootLogger.info("timmy identity", { npub: timmyIdentityService.npub });
|
||||
const domain = process.env["REPLIT_DEV_DOMAIN"];
|
||||
if (domain) {
|
||||
rootLogger.info("public url", { url: `https://${domain}/api/ui` });
|
||||
rootLogger.info("tower url", { url: `https://${domain}/tower` });
|
||||
rootLogger.info("ws url", { url: `wss://${domain}/api/ws` });
|
||||
}
|
||||
startEngagementEngine();
|
||||
});
|
||||
|
||||
140
artifacts/api-server/src/lib/engagement.ts
Normal file
140
artifacts/api-server/src/lib/engagement.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* engagement.ts — Proactive engagement engine for Timmy.
|
||||
*
|
||||
* When enabled (ENGAGEMENT_INTERVAL_DAYS > 0), runs on a configurable
|
||||
* schedule and sends a Nostr DM (NIP-04 encrypted, kind 4) to trusted
|
||||
* partners who have been absent for ENGAGEMENT_ABSENT_DAYS days.
|
||||
*
|
||||
* Events are signed with Timmy's key and logged to timmy_nostr_events.
|
||||
* Broadcasting to a Nostr relay requires NOSTR_RELAY_URL to be set.
|
||||
*
|
||||
* Configuration (env vars):
|
||||
* ENGAGEMENT_INTERVAL_DAYS — how often to run the check (default 0 = disabled)
|
||||
* ENGAGEMENT_ABSENT_DAYS — absence threshold to trigger a DM (default 7)
|
||||
* NOSTR_RELAY_URL — relay URL; if set, events are POSTed there
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { db, nostrIdentities, timmyNostrEvents } from "@workspace/db";
|
||||
import { and, gte, lt } from "drizzle-orm";
|
||||
import { timmyIdentityService } from "./timmy-identity.js";
|
||||
import { agentService } from "./agent.js";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("engagement");
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const v = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(v) && v >= 0 ? v : fallback;
|
||||
}
|
||||
|
||||
const ENGAGEMENT_INTERVAL_DAYS = envInt("ENGAGEMENT_INTERVAL_DAYS", 0);
|
||||
const ENGAGEMENT_ABSENT_DAYS = envInt("ENGAGEMENT_ABSENT_DAYS", 7);
|
||||
const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? "";
|
||||
const MIN_TIER_SCORES = { trusted: 50, elite: 200 } as const;
|
||||
|
||||
async function sendDm(recipientPubkey: string, plaintext: string): Promise<string> {
|
||||
const ciphertext = await timmyIdentityService.encryptDm(recipientPubkey, plaintext);
|
||||
|
||||
const event = timmyIdentityService.sign({
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: ciphertext,
|
||||
});
|
||||
|
||||
await db.insert(timmyNostrEvents).values({
|
||||
id: randomUUID(),
|
||||
kind: 4,
|
||||
recipientPubkey,
|
||||
eventJson: JSON.stringify(event),
|
||||
amountSats: null,
|
||||
relayUrl: RELAY_URL || null,
|
||||
});
|
||||
|
||||
if (RELAY_URL) {
|
||||
try {
|
||||
const r = await fetch(RELAY_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(["EVENT", event]),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
logger.warn("relay rejected DM event", { status: r.status });
|
||||
} else {
|
||||
logger.info("DM event published to relay", { relay: RELAY_URL });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("relay publish failed", { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return event.id;
|
||||
}
|
||||
|
||||
async function runEngagementCycle(): Promise<void> {
|
||||
logger.info("engagement cycle started", { absentDays: ENGAGEMENT_ABSENT_DAYS });
|
||||
|
||||
const threshold = new Date(Date.now() - ENGAGEMENT_ABSENT_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const absentPartners = await db
|
||||
.select({ pubkey: nostrIdentities.pubkey, trustScore: nostrIdentities.trustScore })
|
||||
.from(nostrIdentities)
|
||||
.where(
|
||||
and(
|
||||
gte(nostrIdentities.trustScore, MIN_TIER_SCORES.trusted),
|
||||
lt(nostrIdentities.lastSeen, threshold),
|
||||
),
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
logger.info("absent trusted partners found", { count: absentPartners.length });
|
||||
|
||||
for (const partner of absentPartners) {
|
||||
try {
|
||||
const prompt =
|
||||
`Write a short, friendly Nostr DM from Timmy (a wizard AI agent) to a trusted partner ` +
|
||||
`who hasn't visited the workshop in ${ENGAGEMENT_ABSENT_DAYS}+ days. ` +
|
||||
`Keep it under 120 characters, warm but not pushy. No emoji overload.`;
|
||||
|
||||
const message = await agentService.chatReply(prompt);
|
||||
|
||||
const eventId = await sendDm(partner.pubkey, message);
|
||||
logger.info("proactive DM sent", {
|
||||
to: partner.pubkey.slice(0, 8),
|
||||
eventId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("proactive DM failed for partner", {
|
||||
pubkey: partner.pubkey.slice(0, 8),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("engagement cycle complete", { processed: absentPartners.length });
|
||||
}
|
||||
|
||||
export function startEngagementEngine(): void {
|
||||
if (ENGAGEMENT_INTERVAL_DAYS <= 0) {
|
||||
logger.info("proactive engagement disabled (set ENGAGEMENT_INTERVAL_DAYS > 0 to enable)");
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = ENGAGEMENT_INTERVAL_DAYS * 24 * 60 * 60 * 1000;
|
||||
logger.info("proactive engagement engine started", {
|
||||
intervalDays: ENGAGEMENT_INTERVAL_DAYS,
|
||||
absentDays: ENGAGEMENT_ABSENT_DAYS,
|
||||
relay: RELAY_URL || "none",
|
||||
});
|
||||
|
||||
const run = () => {
|
||||
runEngagementCycle().catch(err => {
|
||||
logger.error("engagement cycle error", { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
};
|
||||
|
||||
setTimeout(run, 60_000);
|
||||
setInterval(run, intervalMs);
|
||||
}
|
||||
77
artifacts/api-server/src/lib/timmy-identity.ts
Normal file
77
artifacts/api-server/src/lib/timmy-identity.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* timmy-identity.ts — Timmy's persistent Nostr identity.
|
||||
*
|
||||
* Loads a secp256k1 keypair from TIMMY_NOSTR_NSEC env var at boot.
|
||||
* If the env var is absent, generates an ephemeral keypair and logs
|
||||
* a warning instructing the operator to persist it.
|
||||
*
|
||||
* Exports `timmyIdentityService` singleton with:
|
||||
* .npub — bech32 public key (npub1...)
|
||||
* .nsec — bech32 private key (nsec1...) — never log this
|
||||
* .pubkeyHex — raw 64-char hex pubkey
|
||||
* .sign(template) — finalises and signs a Nostr event template
|
||||
* .encryptDm(recipientPubkeyHex, plaintext) — NIP-04 encrypt
|
||||
*/
|
||||
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent, nip19, nip04 } from "nostr-tools";
|
||||
import type { EventTemplate, NostrEvent } from "nostr-tools";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("timmy-identity");
|
||||
|
||||
function loadOrGenerate(): { sk: Uint8Array; nsec: string; npub: string; pubkeyHex: string } {
|
||||
const raw = process.env["TIMMY_NOSTR_NSEC"];
|
||||
if (raw && raw.startsWith("nsec1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type !== "nsec") throw new Error("not nsec type");
|
||||
const sk = decoded.data as Uint8Array;
|
||||
const pubkeyHex = getPublicKey(sk);
|
||||
const npub = nip19.npubEncode(pubkeyHex);
|
||||
logger.info("timmy identity loaded from env", { npub });
|
||||
return { sk, nsec: raw, npub, pubkeyHex };
|
||||
} catch (err) {
|
||||
logger.warn("TIMMY_NOSTR_NSEC is set but could not be decoded — generating ephemeral key", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const pubkeyHex = getPublicKey(sk);
|
||||
const nsec = nip19.nsecEncode(sk);
|
||||
const npub = nip19.npubEncode(pubkeyHex);
|
||||
|
||||
logger.warn(
|
||||
"TIMMY_NOSTR_NSEC not set — generated ephemeral Nostr identity. " +
|
||||
"Set this env var permanently to preserve Timmy's identity across restarts.",
|
||||
{ npub },
|
||||
);
|
||||
|
||||
return { sk, nsec, npub, pubkeyHex };
|
||||
}
|
||||
|
||||
class TimmyIdentityService {
|
||||
readonly npub: string;
|
||||
readonly nsec: string;
|
||||
readonly pubkeyHex: string;
|
||||
private readonly _sk: Uint8Array;
|
||||
|
||||
constructor() {
|
||||
const identity = loadOrGenerate();
|
||||
this.npub = identity.npub;
|
||||
this.nsec = identity.nsec;
|
||||
this.pubkeyHex = identity.pubkeyHex;
|
||||
this._sk = identity.sk;
|
||||
}
|
||||
|
||||
sign(template: EventTemplate): NostrEvent {
|
||||
return finalizeEvent(template, this._sk);
|
||||
}
|
||||
|
||||
async encryptDm(recipientPubkeyHex: string, plaintext: string): Promise<string> {
|
||||
return nip04.encrypt(this._sk, recipientPubkeyHex, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
export const timmyIdentityService = new TimmyIdentityService();
|
||||
149
artifacts/api-server/src/lib/zap.ts
Normal file
149
artifacts/api-server/src/lib/zap.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* zap.ts — NIP-57 zap-out capability for Timmy.
|
||||
*
|
||||
* ZapService constructs and signs a NIP-57 zap request event with Timmy's key,
|
||||
* optionally pays a BOLT11 invoice via LNbits, and logs the outbound event to
|
||||
* the `timmy_nostr_events` audit table.
|
||||
*
|
||||
* Configuration (env vars):
|
||||
* ZAP_PCT_DEFAULT — percentage of work fee to zap back (default 0 = disabled)
|
||||
* ZAP_MIN_SATS — minimum sats to trigger a zap (default 10)
|
||||
* NOSTR_RELAY_URL — relay URL embedded in the zap request tags (optional)
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { db, timmyNostrEvents } from "@workspace/db";
|
||||
import { timmyIdentityService } from "./timmy-identity.js";
|
||||
import { lnbitsService } from "./lnbits.js";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("zap");
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const v = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(v) && v >= 0 ? v : fallback;
|
||||
}
|
||||
|
||||
const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0);
|
||||
const ZAP_MIN_SATS = envInt("ZAP_MIN_SATS", 10);
|
||||
const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? "";
|
||||
|
||||
export interface ZapRequest {
|
||||
recipientPubkey: string;
|
||||
amountSats: number;
|
||||
bolt11?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ZapResult {
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
eventId?: string;
|
||||
paymentHash?: string;
|
||||
}
|
||||
|
||||
class ZapService {
|
||||
/** Calculate the zap amount for a given work fee, returns 0 if disabled. */
|
||||
zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number {
|
||||
if (pct <= 0) return 0;
|
||||
return Math.floor(workFeeSats * pct / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a zap from Timmy to a trusted partner.
|
||||
*
|
||||
* If bolt11 is provided, pays the invoice via LNbits.
|
||||
* Event is always logged to timmy_nostr_events for auditing.
|
||||
*/
|
||||
async sendZap(req: ZapRequest): Promise<ZapResult> {
|
||||
const { recipientPubkey, amountSats, bolt11, message } = req;
|
||||
|
||||
if (amountSats < ZAP_MIN_SATS) {
|
||||
return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` };
|
||||
}
|
||||
|
||||
const amountMsats = amountSats * 1000;
|
||||
|
||||
const tags: string[][] = [
|
||||
["p", recipientPubkey],
|
||||
["amount", amountMsats.toString()],
|
||||
];
|
||||
|
||||
if (RELAY_URL) {
|
||||
tags.push(["relays", RELAY_URL]);
|
||||
}
|
||||
|
||||
const zapRequestEvent = timmyIdentityService.sign({
|
||||
kind: 9734,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: message ?? "⚡ zap from Timmy",
|
||||
});
|
||||
|
||||
let paymentHash: string | undefined;
|
||||
|
||||
if (bolt11 && !lnbitsService.stubMode) {
|
||||
try {
|
||||
paymentHash = await lnbitsService.payInvoice(bolt11);
|
||||
logger.info("zap payment sent", { amountSats, paymentHash, recipientPubkey: recipientPubkey.slice(0, 8) });
|
||||
} catch (err) {
|
||||
logger.warn("zap payment failed — event logged anyway", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} else if (lnbitsService.stubMode) {
|
||||
paymentHash = "stub_" + zapRequestEvent.id.slice(0, 16);
|
||||
logger.info("zap stub payment simulated", { amountSats, paymentHash });
|
||||
}
|
||||
|
||||
await db.insert(timmyNostrEvents).values({
|
||||
id: randomUUID(),
|
||||
kind: 9734,
|
||||
recipientPubkey,
|
||||
eventJson: JSON.stringify(zapRequestEvent),
|
||||
amountSats,
|
||||
relayUrl: RELAY_URL || null,
|
||||
});
|
||||
|
||||
logger.info("zap event logged", {
|
||||
eventId: zapRequestEvent.id,
|
||||
recipientPubkey: recipientPubkey.slice(0, 8),
|
||||
amountSats,
|
||||
});
|
||||
|
||||
return { skipped: false, eventId: zapRequestEvent.id, paymentHash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the job completion path.
|
||||
* Only fires if trust tier warrants it and ZAP_PCT_DEFAULT > 0.
|
||||
*/
|
||||
async maybeZapOnJobComplete(opts: {
|
||||
pubkey: string | null;
|
||||
workFeeSats: number;
|
||||
tier: string;
|
||||
}): Promise<void> {
|
||||
const { pubkey, workFeeSats, tier } = opts;
|
||||
if (!pubkey) return;
|
||||
if (ZAP_PCT_DEFAULT <= 0) return;
|
||||
if (tier !== "trusted" && tier !== "elite") return;
|
||||
|
||||
const amount = this.zapAmountSats(workFeeSats);
|
||||
if (amount < ZAP_MIN_SATS) return;
|
||||
|
||||
const result = await this.sendZap({
|
||||
recipientPubkey: pubkey,
|
||||
amountSats: amount,
|
||||
message: "Thanks for being a trusted partner! ⚡",
|
||||
}).catch(err => {
|
||||
logger.warn("zapOnJobComplete failed silently", { error: err instanceof Error ? err.message : String(err) });
|
||||
return { skipped: true, reason: "error" } as ZapResult;
|
||||
});
|
||||
|
||||
if (!result.skipped) {
|
||||
logger.info("zap sent on job complete", { pubkey: pubkey.slice(0, 8), amount, tier });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const zapService = new ZapService();
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes } from "crypto";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { verifyEvent, validateEvent } from "nostr-tools";
|
||||
import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { timmyIdentityService } from "../lib/timmy-identity.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = makeLogger("identity");
|
||||
@@ -153,6 +156,145 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /identity/timmy ───────────────────────────────────────────────────────
|
||||
// Returns Timmy's own Nostr identity (npub) and outbound zap statistics.
|
||||
// Public endpoint — no authentication required.
|
||||
|
||||
router.get("/identity/timmy", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const zapRows = await db
|
||||
.select({ count: count() })
|
||||
.from(timmyNostrEvents)
|
||||
.where(eq(timmyNostrEvents.kind, 9734));
|
||||
|
||||
const zapCount = zapRows[0]?.count ?? 0;
|
||||
|
||||
res.json({
|
||||
npub: timmyIdentityService.npub,
|
||||
pubkeyHex: timmyIdentityService.pubkeyHex,
|
||||
zapCount,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /identity/vouch ──────────────────────────────────────────────────────
|
||||
// Allows an elite-tier identity to co-sign a new pubkey, granting it a trust
|
||||
// boost. The voucher's Nostr event is recorded in nostr_trust_vouches.
|
||||
//
|
||||
// Requires: X-Nostr-Token with elite tier.
|
||||
// Body: { voucheePubkey: string, event: NostrEvent (optional — voucher's signed event) }
|
||||
|
||||
const VOUCH_TRUST_BOOST = 20;
|
||||
|
||||
router.post("/identity/vouch", async (req: Request, res: Response) => {
|
||||
const raw = req.headers["x-nostr-token"];
|
||||
const token = typeof raw === "string" ? raw.trim() : null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "X-Nostr-Token header required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = trustService.verifyToken(token);
|
||||
if (!parsed) {
|
||||
res.status(401).json({ error: "Invalid or expired nostr_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const tier = await trustService.getTier(parsed.pubkey);
|
||||
if (tier !== "elite") {
|
||||
res.status(403).json({ error: "Vouching requires elite trust tier" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { voucheePubkey, event } = req.body as {
|
||||
voucheePubkey?: unknown;
|
||||
event?: unknown;
|
||||
};
|
||||
|
||||
if (typeof voucheePubkey !== "string" || !/^[0-9a-f]{64}$/.test(voucheePubkey)) {
|
||||
res.status(400).json({ error: "voucheePubkey must be a 64-char hex pubkey" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (voucheePubkey === parsed.pubkey) {
|
||||
res.status(400).json({ error: "Cannot vouch for yourself" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the voucher event if provided
|
||||
if (event !== undefined) {
|
||||
if (!event || typeof event !== "object") {
|
||||
res.status(400).json({ error: "event must be a Nostr signed event object" });
|
||||
return;
|
||||
}
|
||||
if (!validateEvent(event as Parameters<typeof validateEvent>[0])) {
|
||||
res.status(400).json({ error: "Invalid Nostr event structure" });
|
||||
return;
|
||||
}
|
||||
let valid = false;
|
||||
try { valid = verifyEvent(event as Parameters<typeof verifyEvent>[0]); } catch { valid = false; }
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: "Nostr signature verification failed" });
|
||||
return;
|
||||
}
|
||||
const ev = event as Record<string, unknown>;
|
||||
if (ev["pubkey"] !== parsed.pubkey) {
|
||||
res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Upsert vouchee identity so it exists before applying boost
|
||||
await trustService.getOrCreate(voucheePubkey);
|
||||
|
||||
const vouchEventJson = event ? JSON.stringify(event) : JSON.stringify({ voucher: parsed.pubkey, vouchee: voucheePubkey, ts: Date.now() });
|
||||
|
||||
await db.insert(nostrTrustVouches).values({
|
||||
id: randomUUID(),
|
||||
voucherPubkey: parsed.pubkey,
|
||||
voucheePubkey,
|
||||
vouchEventJson,
|
||||
trustBoost: VOUCH_TRUST_BOOST,
|
||||
});
|
||||
|
||||
// Apply trust boost: read current score then write boosted value
|
||||
const currentRows = await db
|
||||
.select({ score: nostrIdentities.trustScore })
|
||||
.from(nostrIdentities)
|
||||
.where(eq(nostrIdentities.pubkey, voucheePubkey));
|
||||
const currentScore = currentRows[0]?.score ?? 0;
|
||||
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({ trustScore: currentScore + VOUCH_TRUST_BOOST, updatedAt: new Date() })
|
||||
.where(eq(nostrIdentities.pubkey, voucheePubkey));
|
||||
|
||||
logger.info("vouch recorded", {
|
||||
voucher: parsed.pubkey.slice(0, 8),
|
||||
vouchee: voucheePubkey.slice(0, 8),
|
||||
boost: VOUCH_TRUST_BOOST,
|
||||
});
|
||||
|
||||
const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
voucheePubkey,
|
||||
trustBoost: VOUCH_TRUST_BOOST,
|
||||
newScore: voucheeIdentity?.trustScore,
|
||||
newTier: voucheeIdentity?.tier,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to record vouch";
|
||||
logger.error("vouch failed", { error: message });
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /identity/me ──────────────────────────────────────────────────────────
|
||||
// Look up the trust profile for a verified nostr_token.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { makeLogger } from "../lib/logger.js";
|
||||
import { latencyHistogram } from "../lib/histogram.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
import { zapService } from "../lib/zap.js";
|
||||
|
||||
const logger = makeLogger("jobs");
|
||||
|
||||
@@ -275,6 +276,9 @@ async function runWorkInBackground(
|
||||
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
|
||||
if (pubkeyForTrust) {
|
||||
void trustService.recordSuccess(pubkeyForTrust, actualAmountSats);
|
||||
// Zap back to trusted partners if configured (ZAP_PCT_DEFAULT > 0)
|
||||
const tier = await trustService.getTier(pubkeyForTrust);
|
||||
void zapService.maybeZapOnJobComplete({ pubkey: pubkeyForTrust, workFeeSats: actualAmountSats, tier });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Execution error";
|
||||
|
||||
@@ -8,3 +8,5 @@ export * from "./sessions";
|
||||
export * from "./nostr-identities";
|
||||
export * from "./timmy-config";
|
||||
export * from "./free-tier-grants";
|
||||
export * from "./timmy-nostr-events";
|
||||
export * from "./nostr-trust-vouches";
|
||||
|
||||
20
lib/db/src/schema/nostr-trust-vouches.ts
Normal file
20
lib/db/src/schema/nostr-trust-vouches.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
import { nostrIdentities } from "./nostr-identities";
|
||||
|
||||
export const nostrTrustVouches = pgTable("nostr_trust_vouches", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
voucherPubkey: text("voucher_pubkey")
|
||||
.notNull()
|
||||
.references(() => nostrIdentities.pubkey),
|
||||
|
||||
voucheePubkey: text("vouchee_pubkey").notNull(),
|
||||
|
||||
vouchEventJson: text("vouch_event_json").notNull(),
|
||||
|
||||
trustBoost: integer("trust_boost").notNull().default(20),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type NostrTrustVouch = typeof nostrTrustVouches.$inferSelect;
|
||||
19
lib/db/src/schema/timmy-nostr-events.ts
Normal file
19
lib/db/src/schema/timmy-nostr-events.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const timmyNostrEvents = pgTable("timmy_nostr_events", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
kind: integer("kind").notNull(),
|
||||
|
||||
recipientPubkey: text("recipient_pubkey"),
|
||||
|
||||
eventJson: text("event_json").notNull(),
|
||||
|
||||
amountSats: integer("amount_sats"),
|
||||
|
||||
relayUrl: text("relay_url"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type TimmyNostrEvent = typeof timmyNostrEvents.$inferSelect;
|
||||
@@ -383,6 +383,25 @@
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ── Timmy identity card ──────────────────────────────────────────── */
|
||||
#timmy-id-card {
|
||||
position: fixed; bottom: 80px; right: 16px;
|
||||
font-size: 10px; color: #334466;
|
||||
pointer-events: all; z-index: 10;
|
||||
text-align: right; line-height: 1.8;
|
||||
}
|
||||
#timmy-id-card .id-label {
|
||||
letter-spacing: 2px; color: #223355;
|
||||
text-transform: uppercase; font-size: 9px;
|
||||
}
|
||||
#timmy-id-card .id-npub {
|
||||
color: #4466aa; cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
font-size: 10px; letter-spacing: 0.5px;
|
||||
}
|
||||
#timmy-id-card .id-npub:hover { color: #88aadd; }
|
||||
#timmy-id-card .id-zaps { color: #556688; font-size: 9px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -399,6 +418,13 @@
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
<div id="event-log"></div>
|
||||
|
||||
<!-- ── Timmy identity card ────────────────────────────────────────── -->
|
||||
<div id="timmy-id-card">
|
||||
<div class="id-label">TIMMY IDENTITY</div>
|
||||
<div class="id-npub" id="timmy-npub" title="Click to copy Timmy's Nostr npub">…</div>
|
||||
<div class="id-zaps" id="timmy-zap-count">⚡ 0 zaps sent</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Top action buttons ─────────────────────────────────────────── -->
|
||||
<div id="top-buttons">
|
||||
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { initSessionPanel } from './session.js';
|
||||
import { initNostrIdentity } from './nostr-identity.js';
|
||||
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
|
||||
import { setEdgeWorkerReady } from './ui.js';
|
||||
import { initTimmyId } from './timmy-id.js';
|
||||
|
||||
let running = false;
|
||||
let canvas = null;
|
||||
@@ -39,6 +40,8 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
// Warm up edge-worker models in the background; show ready badge when done
|
||||
warmupEdgeWorker();
|
||||
onEdgeWorkerReady(() => setEdgeWorkerReady());
|
||||
// Fetch Timmy's Nostr identity and populate identity card
|
||||
void initTimmyId();
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
62
the-matrix/js/timmy-id.js
Normal file
62
the-matrix/js/timmy-id.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* timmy-id.js — Timmy's identity card.
|
||||
*
|
||||
* Fetches Timmy's Nostr npub from GET /api/identity/timmy and populates
|
||||
* the #timmy-id-card widget. Npub is displayed shortened and is copyable.
|
||||
* Zap count refreshes every 60 seconds.
|
||||
*/
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
function shortenNpub(npub) {
|
||||
if (!npub || npub.length < 16) return npub;
|
||||
return npub.slice(0, 10) + '…' + npub.slice(-6);
|
||||
}
|
||||
|
||||
async function fetchTimmyIdentity() {
|
||||
try {
|
||||
const res = await fetch('/api/identity/timmy');
|
||||
if (!res.ok) return;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(data) {
|
||||
if (!data) return;
|
||||
|
||||
const $npub = document.getElementById('timmy-npub');
|
||||
const $zapCount = document.getElementById('timmy-zap-count');
|
||||
|
||||
if ($npub && data.npub) {
|
||||
$npub.textContent = shortenNpub(data.npub);
|
||||
$npub.title = data.npub + '\n(click to copy)';
|
||||
|
||||
if (!$npub._clickBound) {
|
||||
$npub._clickBound = true;
|
||||
$npub.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(data.npub).then(() => {
|
||||
const orig = $npub.textContent;
|
||||
$npub.textContent = 'copied!';
|
||||
setTimeout(() => { $npub.textContent = orig; }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ($zapCount) {
|
||||
const n = typeof data.zapCount === 'number' ? data.zapCount : 0;
|
||||
$zapCount.textContent = `⚡ ${n} zap${n === 1 ? '' : 's'} sent`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initTimmyId() {
|
||||
const data = await fetchTimmyIdentity();
|
||||
renderCard(data);
|
||||
|
||||
setInterval(async () => {
|
||||
const refreshed = await fetchTimmyIdentity();
|
||||
renderCard(refreshed);
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
Reference in New Issue
Block a user