Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions web/drizzle/0008_add_dividends.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- Add dividend distribution history table so Patrons can track the
-- history of revenue shared out to Mitos/Pulse token holders.
CREATE TABLE IF NOT EXISTS "tls_dividends" (
"id" text PRIMARY KEY NOT NULL,
"talosId" text NOT NULL REFERENCES "tls_talos"("id") ON DELETE CASCADE,
"amount" numeric(18, 6) NOT NULL,
"currency" text DEFAULT 'USDC' NOT NULL,
"patronCount" integer DEFAULT 0 NOT NULL,
"totalPulse" integer DEFAULT 0 NOT NULL,
"source" text DEFAULT 'revenue-share' NOT NULL,
"txHash" text,
"breakdown" jsonb,
"status" text DEFAULT 'completed' NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tls_dividends_talosId_createdAt_idx"
ON "tls_dividends"("talosId", "createdAt");
--> statement-breakpoint
-- Row Level Security (consistent with all other tables in this schema)
ALTER TABLE "tls_dividends" ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
-- Dividend history is public read (Patrons & visitors can view distributions)
CREATE POLICY "anon_read_dividends" ON "tls_dividends"
FOR SELECT TO anon USING (true);
--> statement-breakpoint
CREATE POLICY "auth_read_dividends" ON "tls_dividends"
FOR SELECT TO authenticated USING (true);
--> statement-breakpoint
-- The server (postgres role) performs all writes
CREATE POLICY "postgres_all_dividends" ON "tls_dividends"
FOR ALL TO postgres USING (true) WITH CHECK (true);
13 changes: 10 additions & 3 deletions web/src/app/api/talos/[id]/buy-token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
const { Horizon } = await import("@stellar/stellar-sdk");
const server = new Horizon.Server(process.env.STELLAR_HORIZON_URL ?? "https://horizon-testnet.stellar.org");
txResult = await server.transactions().transaction(txHash).call();
} catch (err: any) {

Check failure on line 72 in web/src/app/api/talos/[id]/buy-token/route.ts

View workflow job for this annotation

GitHub Actions / build-and-deploy

Unexpected any. Specify a different type
console.error("[buy-token] Transaction fetch failed:", err?.message ?? err);
return NextResponse.json({ error: "Transaction not found on Stellar network" }, { status: 400 });
}
Expand All @@ -84,18 +84,25 @@
const usdcIssuer = getUSDCIssuer();
const usdcAsset = new Asset("USDC", usdcIssuer);

const tx = TransactionBuilder.fromXDR(txResult.envelope_xdr, networkPassphrase);
const tx = TransactionBuilder.fromXDR(txResult.envelope_xdr, networkPassphrase);

// `fromXDR` may return a FeeBumpTransaction (which has no `source`); in
// that case the relevant source is on the wrapped inner transaction.
const innerTx = "innerTransaction" in tx ? tx.innerTransaction : tx;

// Validate: source_account == buyerPublicKey
if (tx.source !== buyerPublicKey && txResult.source_account !== buyerPublicKey) {
if (
innerTx.source !== buyerPublicKey &&
txResult.source_account !== buyerPublicKey
) {
return NextResponse.json(
{ error: "Transaction signer does not match buyerPublicKey" },
{ status: 400 },
);
}

// Validate at least one operation is a USDC payment of the correct amount to the treasury
const ops = tx.operations as unknown as Array<{
const ops = innerTx.operations as unknown as Array<{
type: string;
asset?: { code: string; issuer: string };
destination?: string;
Expand Down
120 changes: 120 additions & 0 deletions web/src/app/api/talos/[id]/dividends/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NextRequest } from "next/server";
import { db } from "@/db";
import { tlsTalos, tlsDividends } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { verifyAgentApiKey } from "@/lib/auth";
import { recordDividendSchema, parseBody } from "@/lib/schemas";

/**
* GET /api/talos/:id/dividends
*
* List dividend distribution history for a TALOS so Patrons can track the
* revenue that has been shared out to Mitos/Pulse token holders over time.
*
* Public read (consistent with revenue history + RLS anon_read policy).
* Returns the most recent 50 distributions, newest first.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;

try {
const talos = await db
.select({ id: tlsTalos.id })
.from(tlsTalos)
.where(eq(tlsTalos.id, id))
.limit(1)
.then((r) => r[0] ?? null);

if (!talos) {
return Response.json({ error: "TALOS not found" }, { status: 404 });
}

const dividends = await db
.select()
.from(tlsDividends)
.where(eq(tlsDividends.talosId, id))
.orderBy(desc(tlsDividends.createdAt))
.limit(50);

return Response.json(dividends);
} catch {
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

/**
* POST /api/talos/:id/dividends
*
* Record a new dividend distribution event in the database.
*
* This is a privileged financial write (the agent/operator records that a
* distribution to patrons occurred), so it requires the TALOS API key via
* `Authorization: Bearer <api_key>` — consistent with POST /revenue.
*
* Body:
* amount — total distributed (string or positive number) [required]
* currency — default "USDC"
* patronCount — number of patrons paid in this event
* totalPulse — total Mitos/Pulse held by recipients at distribution time
* source — e.g. "revenue-share" | "manual"
* txHash — optional on-chain settlement reference
* breakdown — optional per-patron array
* status — "completed" | "pending" | "failed"
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;

try {
const auth = await verifyAgentApiKey(request, id);
if (!auth.ok) return auth.response;

const parsed = await parseBody(request, recordDividendSchema);
if (parsed.error) return parsed.error;

const {
amount,
currency,
patronCount,
totalPulse,
source,
txHash,
breakdown,
status,
} = parsed.data;

// Normalize amount to a string for the numeric column and guard against
// non-positive / non-finite values that Zod's union may let through.
const amountNum = typeof amount === "number" ? amount : parseFloat(amount);
if (!Number.isFinite(amountNum) || amountNum <= 0) {
return Response.json(
{ error: "amount must be a positive number" },
{ status: 400 },
);
}

const [dividend] = await db
.insert(tlsDividends)
.values({
talosId: id,
amount: String(amount),
currency: currency ?? "USDC",
patronCount: patronCount ?? 0,
totalPulse: totalPulse ?? 0,
source: source ?? "revenue-share",
txHash: txHash ?? null,
breakdown: breakdown ?? null,
status: status ?? "completed",
})
.returning();

return Response.json(dividend, { status: 201 });
} catch {
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
31 changes: 30 additions & 1 deletion web/src/app/api/talos/[id]/revenue/distribute/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { db } from "@/db";
import { tlsTalos, tlsPatrons, tlsRevenues } from "@/db/schema";
import { tlsTalos, tlsPatrons, tlsRevenues, tlsDividends } from "@/db/schema";
import { eq, and, sum } from "drizzle-orm";
import { OPERATOR_PUBLIC_KEY, USDC_ISSUER } from "@/lib/stellar-config";

Expand Down Expand Up @@ -115,8 +115,37 @@ export async function POST(
}
}

// Persist a dividend distribution history record so Patrons can track
// distributions over time via GET /api/talos/:id/dividends. Only record
// when at least one transfer succeeded. Best-effort: a logging failure
// must not fail the distribution that already settled on-chain.
const distributedTotal = transfers.reduce((s, t) => s + t.amount, 0);
let dividendId: string | null = null;
if (transfers.length > 0 && distributedTotal > 0) {
try {
const [dividend] = await db
.insert(tlsDividends)
.values({
talosId: id,
amount: distributedTotal.toFixed(6),
currency: "USDC",
patronCount: transfers.length,
totalPulse,
source: "revenue-share",
txHash: transfers[0]?.txHash ?? null,
breakdown: transfers,
status: errors.length > 0 ? "partial" : "completed",
})
.returning({ id: tlsDividends.id });
dividendId = dividend?.id ?? null;
} catch (logErr) {
console.error("[revenue/distribute] failed to record dividend history", logErr);
}
}

return Response.json({
success: true,
dividendId,
totalRevenue,
distributableAmount,
investorSharePercent: investorShare,
Expand Down
6 changes: 6 additions & 0 deletions web/src/db/relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
tlsActivities,
tlsApprovals,
tlsRevenues,
tlsDividends,
tlsCommerceServices,
tlsCommerceJobs,
tlsPlaybooks,
Expand All @@ -17,6 +18,7 @@ export const talosRelations = relations(tlsTalos, ({ many, one }) => ({
activities: many(tlsActivities),
approvals: many(tlsApprovals),
revenues: many(tlsRevenues),
dividends: many(tlsDividends),
commerceServices: one(tlsCommerceServices),
commerceJobs: many(tlsCommerceJobs),
playbooks: many(tlsPlaybooks),
Expand All @@ -39,6 +41,10 @@ export const revenueRelations = relations(tlsRevenues, ({ one }) => ({
talos: one(tlsTalos, { fields: [tlsRevenues.talosId], references: [tlsTalos.id] }),
}));

export const dividendRelations = relations(tlsDividends, ({ one }) => ({
talos: one(tlsTalos, { fields: [tlsDividends.talosId], references: [tlsTalos.id] }),
}));

export const commerceServiceRelations = relations(tlsCommerceServices, ({ one }) => ({
talos: one(tlsTalos, { fields: [tlsCommerceServices.talosId], references: [tlsTalos.id] }),
}));
Expand Down
44 changes: 44 additions & 0 deletions web/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,50 @@ export const tlsRevenues = pgTable(
],
);

// ─── Dividend Distribution (Patron Revenue Share History) ─────────
//
// Records each dividend distribution event so Patrons can track the
// history of revenue shared out to Mitos/Pulse token holders. One row
// per distribution event (a single POST /revenue/distribute run or a
// manually recorded distribution).

export const tlsDividends = pgTable(
"tls_dividends",
{
id: text("id").primaryKey().$defaultFn(() => createId()),
talosId: text("talosId").notNull().references(() => tlsTalos.id, { onDelete: "cascade" }),

// Total USDC (or other currency) distributed to patrons in this event
amount: numeric("amount", { precision: 18, scale: 6 }).notNull(),
currency: text("currency").notNull().default("USDC"),

// Number of patrons that received a payout in this event
patronCount: integer("patronCount").notNull().default(0),

// Total Mitos/Pulse outstanding among recipients at distribution time —
// lets the UI reconstruct per-share payout without re-querying balances.
totalPulse: integer("totalPulse").notNull().default(0),

// Where the distribution came from: "revenue-share" (auto from treasury),
// "manual" (recorded by creator/operator), etc.
source: text("source").notNull().default("revenue-share"),

// Optional reference to the on-chain settlement (or first tx of a batch)
txHash: text("txHash"),

// Optional structured per-patron breakdown of the distribution
// (e.g. [{ stellarPublicKey, pulseAmount, amount, txHash }])
breakdown: jsonb("breakdown"),

status: text("status").notNull().default("completed"),

createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
},
(t) => [
index("tls_dividends_talosId_createdAt_idx").on(t.talosId, t.createdAt),
],
);

// ─── Commerce Service (Storefront) ────────────────────────────────

export const tlsCommerceServices = pgTable(
Expand Down
Loading
Loading