Skip to content

Commit 59c1963

Browse files
authored
Merge pull request #627 from Merit-Systems/br/x402-transactions
X402 Transactions
2 parents 3836488 + efd8caf commit 59c1963

File tree

43 files changed

+2146
-637
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2146
-637
lines changed

packages/app/control/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"dependencies": {
3838
"@auth/core": "^0.40.0",
3939
"@auth/prisma-adapter": "^2.10.0",
40+
"@coinbase/cdp-sdk": "^1.34.0",
41+
"@coinbase/x402": "^0.6.4",
4042
"@hookform/resolvers": "^5.2.1",
4143
"@icons-pack/react-simple-icons": "^13.7.0",
4244
"@merit-systems/sdk": "0.0.8",
@@ -88,7 +90,6 @@
8890
"autonumeric": "^4.10.8",
8991
"class-variance-authority": "^0.7.1",
9092
"clsx": "^2.1.1",
91-
"@coinbase/x402": "^0.6.4",
9293
"cors": "^2.8.5",
9394
"date-fns": "^4.1.0",
9495
"dotenv": "^16.4.5",
@@ -137,9 +138,9 @@
137138
"devDependencies": {
138139
"@eslint/eslintrc": "^3",
139140
"@faker-js/faker": "^9.9.0",
141+
"@next/eslint-plugin-next": "^15.5.3",
140142
"@types/cors": "^2.8.17",
141143
"@types/express": "^5.0.0",
142-
"@next/eslint-plugin-next": "^15.5.3",
143144
"@types/node": "^20",
144145
"@types/react": "19.1.10",
145146
"@types/react-dom": "19.1.7",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- CreateEnum
2+
CREATE TYPE "EnumTransactionType" AS ENUM ('X402', 'BALANCE');
3+
4+
-- DropForeignKey
5+
ALTER TABLE "public"."transactions" DROP CONSTRAINT "transactions_echoAppId_fkey";
6+
7+
-- AlterTable
8+
ALTER TABLE "transactions" ADD COLUMN "transactionType" "EnumTransactionType" NOT NULL DEFAULT 'BALANCE',
9+
ALTER COLUMN "userId" DROP NOT NULL,
10+
ALTER COLUMN "echoAppId" DROP NOT NULL;
11+
12+
-- AddForeignKey
13+
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_echoAppId_fkey" FOREIGN KEY ("echoAppId") REFERENCES "echo_apps"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "transactions" ADD COLUMN "echoProfit" DECIMAL(65,14) NOT NULL DEFAULT 0.0;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterEnum
2+
ALTER TYPE "EnumPayoutType" ADD VALUE 'ECHO_PROFIT';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterEnum
2+
ALTER TYPE "EnumPayoutType" ADD VALUE 'APP_PROFIT';

packages/app/control/prisma/schema.prisma

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ enum EnumPayoutStatus {
231231
enum EnumPayoutType {
232232
MARKUP
233233
REFERRAL
234+
ECHO_PROFIT
235+
APP_PROFIT
234236
}
235237

236238
model Payout {
@@ -301,32 +303,39 @@ enum EnumPaymentSource {
301303
balance
302304
}
303305

306+
enum EnumTransactionType {
307+
X402
308+
BALANCE
309+
}
310+
304311
model Transaction {
305312
id String @id @default(uuid()) @db.Uuid
306313
transactionMetadataId String? @db.Uuid
307314
totalCost Decimal @default(0.0) @db.Decimal(65, 14)
308315
appProfit Decimal @default(0.0) @db.Decimal(65, 14)
316+
echoProfit Decimal @default(0.0) @db.Decimal(65, 14)
309317
markUpProfit Decimal @default(0.0) @db.Decimal(65, 14)
310318
referralProfit Decimal @default(0.0) @db.Decimal(65, 14)
311319
rawTransactionCost Decimal @default(0.0) @db.Decimal(65, 14)
312320
status String?
313321
isArchived Boolean @default(false)
314322
archivedAt DateTime? @db.Timestamptz(6)
315323
createdAt DateTime @default(now()) @db.Timestamptz(6)
316-
userId String @db.Uuid
317-
echoAppId String @db.Uuid
324+
transactionType EnumTransactionType @default(BALANCE)
325+
userId String? @db.Uuid
326+
echoAppId String? @db.Uuid
318327
apiKeyId String? @db.Uuid
319328
markUpId String? @db.Uuid
320329
spendPoolId String? @db.Uuid
321330
userSpendPoolUsageId String? @db.Uuid
322331
referralCodeId String? @db.Uuid
323332
referrerRewardId String? @db.Uuid
324333
apiKey ApiKey? @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
325-
echoApp EchoApp @relation(fields: [echoAppId], references: [id])
334+
echoApp EchoApp? @relation(fields: [echoAppId], references: [id])
326335
markUp MarkUp? @relation(fields: [markUpId], references: [id])
327336
spendPool SpendPool? @relation(fields: [spendPoolId], references: [id])
328337
transactionMetadata TransactionMetadata? @relation(fields: [transactionMetadataId], references: [id])
329-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
338+
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
330339
userSpendPoolUsage UserSpendPoolUsage? @relation(fields: [userSpendPoolUsageId], references: [id])
331340
referralCode ReferralCode? @relation(fields: [referralCodeId], references: [id])
332341
referrerReward ReferralReward? @relation(fields: [referrerRewardId], references: [id])
@@ -506,4 +515,4 @@ model VideoGenerationX402 {
506515
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
507516
echoApp EchoApp? @relation(fields: [echoAppId], references: [id], onDelete: Cascade)
508517
@@map("video_generation_x402")
509-
}
518+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
'use client';
2+
3+
import {
4+
Card,
5+
CardContent,
6+
CardDescription,
7+
CardHeader,
8+
CardTitle,
9+
} from '@/components/ui/card';
10+
import { Skeleton } from '@/components/ui/skeleton';
11+
import { Button } from '@/components/ui/button';
12+
import { api } from '@/trpc/client';
13+
import { toast } from 'sonner';
14+
import {
15+
Table,
16+
TableBody,
17+
TableCell,
18+
TableHead,
19+
TableHeader,
20+
TableRow,
21+
} from '@/components/ui/table';
22+
import { useState } from 'react';
23+
24+
export function AppX402ProfitTotal() {
25+
const [processingAppIds, setProcessingAppIds] = useState<Set<string>>(
26+
new Set()
27+
);
28+
const [isSendingAll, setIsSendingAll] = useState(false);
29+
const utils = api.useUtils();
30+
31+
const { data: totalProfit, isLoading: isTotalLoading } =
32+
api.admin.wallet.getX402AppProfit.useQuery();
33+
34+
const { data: appBreakdown, isLoading: isBreakdownLoading } =
35+
api.admin.wallet.getX402AppProfitByApp.useQuery();
36+
37+
const payoutMutation = api.admin.wallet.payoutX402AppProfit.useMutation({
38+
onSuccess: data => {
39+
toast.success('Payout successful!', {
40+
description: `Sent to ECHO_PAYOUTS. Tx: ${data.userOpHash.slice(0, 10)}...`,
41+
});
42+
void utils.admin.wallet.getX402AppProfit.invalidate();
43+
void utils.admin.wallet.getX402AppProfitByApp.invalidate();
44+
},
45+
onError: error => {
46+
toast.error('Payout failed', {
47+
description: error.message,
48+
});
49+
},
50+
onSettled: (data, error, variables) => {
51+
setProcessingAppIds(prev => {
52+
const next = new Set(prev);
53+
next.delete(variables.appId);
54+
return next;
55+
});
56+
},
57+
});
58+
59+
const handlePayout = (appId: string, amount: number) => {
60+
if (amount <= 0) {
61+
toast.error('Invalid amount', {
62+
description: 'Amount must be greater than 0',
63+
});
64+
return;
65+
}
66+
67+
setProcessingAppIds(prev => new Set(prev).add(appId));
68+
payoutMutation.mutate({ appId, amount });
69+
};
70+
71+
const handleSendAll = async () => {
72+
if (!appBreakdown || appBreakdown.length === 0) {
73+
toast.error('No apps to payout');
74+
return;
75+
}
76+
77+
const appsWithProfit = appBreakdown.filter(app => app.remainingProfit > 0);
78+
79+
if (appsWithProfit.length === 0) {
80+
toast.error('No apps with remaining profit');
81+
return;
82+
}
83+
84+
setIsSendingAll(true);
85+
86+
for (const app of appsWithProfit) {
87+
setProcessingAppIds(prev => new Set(prev).add(app.appId));
88+
payoutMutation.mutate({
89+
appId: app.appId,
90+
amount: app.remainingProfit,
91+
});
92+
}
93+
94+
setIsSendingAll(false);
95+
};
96+
97+
return (
98+
<div className="space-y-6">
99+
<Card>
100+
<CardHeader>
101+
<CardTitle>App X402 Profit Total</CardTitle>
102+
<CardDescription>
103+
Total unclaimed profit generated by apps from X402 transactions
104+
</CardDescription>
105+
</CardHeader>
106+
107+
<CardContent className="space-y-6">
108+
<div>
109+
<h3 className="text-sm font-medium text-muted-foreground mb-2">
110+
Total App Profit
111+
</h3>
112+
{isTotalLoading ? (
113+
<Skeleton className="h-10 w-32" />
114+
) : (
115+
<div className="text-3xl font-bold text-green-600">
116+
${(totalProfit ?? 0).toFixed(6)}
117+
</div>
118+
)}
119+
<p className="text-xs text-muted-foreground mt-1">
120+
Available for payout to applications
121+
</p>
122+
</div>
123+
124+
<p className="text-sm text-muted-foreground">
125+
The App Profit represents the sum of all appProfit from X402
126+
transactions minus any payouts already made to applications.
127+
</p>
128+
</CardContent>
129+
</Card>
130+
131+
<Card>
132+
<CardHeader>
133+
<div className="flex items-center justify-between">
134+
<div>
135+
<CardTitle>Profit Breakdown by Application</CardTitle>
136+
<CardDescription>
137+
X402 profit generated by each application
138+
</CardDescription>
139+
</div>
140+
{appBreakdown &&
141+
appBreakdown.some(app => app.remainingProfit > 0) && (
142+
<Button
143+
onClick={handleSendAll}
144+
disabled={isSendingAll || payoutMutation.isPending}
145+
variant="default"
146+
>
147+
{isSendingAll ? 'Processing...' : 'Send All'}
148+
</Button>
149+
)}
150+
</div>
151+
</CardHeader>
152+
<CardContent>
153+
{isBreakdownLoading ? (
154+
<div className="space-y-2">
155+
<Skeleton className="h-12 w-full" />
156+
<Skeleton className="h-12 w-full" />
157+
<Skeleton className="h-12 w-full" />
158+
</div>
159+
) : !appBreakdown || appBreakdown.length === 0 ? (
160+
<p className="text-sm text-muted-foreground text-center py-8">
161+
No app profit data available
162+
</p>
163+
) : (
164+
<div className="overflow-x-auto">
165+
<Table>
166+
<TableHeader>
167+
<TableRow>
168+
<TableHead>Application</TableHead>
169+
<TableHead className="text-right">Total Profit</TableHead>
170+
<TableHead className="text-right">Total Payouts</TableHead>
171+
<TableHead className="text-right">Remaining</TableHead>
172+
<TableHead className="text-right">Actions</TableHead>
173+
</TableRow>
174+
</TableHeader>
175+
<TableBody>
176+
{appBreakdown.map(app => (
177+
<TableRow key={app.appId}>
178+
<TableCell className="font-medium">
179+
{app.appName}
180+
</TableCell>
181+
<TableCell className="text-right font-mono text-sm">
182+
${app.totalProfit.toFixed(6)}
183+
</TableCell>
184+
<TableCell className="text-right font-mono text-sm">
185+
${app.totalPayouts.toFixed(6)}
186+
</TableCell>
187+
<TableCell className="text-right font-mono text-sm">
188+
<span
189+
className={
190+
app.remainingProfit > 0
191+
? 'text-green-600 font-semibold'
192+
: ''
193+
}
194+
>
195+
${app.remainingProfit.toFixed(6)}
196+
</span>
197+
</TableCell>
198+
<TableCell className="text-right">
199+
<Button
200+
onClick={() =>
201+
handlePayout(app.appId, app.remainingProfit)
202+
}
203+
disabled={
204+
app.remainingProfit <= 0 ||
205+
processingAppIds.has(app.appId)
206+
}
207+
size="sm"
208+
variant="outline"
209+
>
210+
{processingAppIds.has(app.appId)
211+
? 'Sending...'
212+
: 'Send'}
213+
</Button>
214+
</TableCell>
215+
</TableRow>
216+
))}
217+
</TableBody>
218+
</Table>
219+
</div>
220+
)}
221+
</CardContent>
222+
</Card>
223+
</div>
224+
);
225+
}

0 commit comments

Comments
 (0)