Skip to content

Commit 5be92a7

Browse files
authored
Merge pull request #384 from akintewe/feat/transak-onramp-integration
2 parents 4350c1a + 3d6e6b0 commit 5be92a7

11 files changed

Lines changed: 921 additions & 12 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* POST /api/wallet/onramp/order
3+
*
4+
* Called by the client (useTransak hook) after a successful Transak order event.
5+
* Upserts the order record in transak_orders tied to the authenticated user.
6+
*
7+
* This is a convenience path for client-initiated upserts. The authoritative
8+
* record update comes via the server-side webhook at /api/webhooks/transak.
9+
*/
10+
11+
import { NextRequest, NextResponse } from "next/server";
12+
import { sql } from "@vercel/postgres";
13+
import { verifySession } from "@/lib/auth/verify-session";
14+
15+
export async function POST(req: NextRequest) {
16+
const session = await verifySession(req);
17+
if (!session.ok) {
18+
return session.response;
19+
}
20+
21+
let body: {
22+
id?: string;
23+
status?: string;
24+
cryptoAmount?: number | null;
25+
cryptoCurrency?: string;
26+
fiatAmount?: number;
27+
fiatCurrency?: string;
28+
walletAddress?: string;
29+
txHash?: string | null;
30+
};
31+
32+
try {
33+
body = await req.json();
34+
} catch {
35+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
36+
}
37+
38+
const {
39+
id,
40+
status,
41+
cryptoAmount,
42+
cryptoCurrency,
43+
fiatAmount,
44+
fiatCurrency,
45+
walletAddress,
46+
txHash,
47+
} = body;
48+
49+
if (!id || typeof id !== "string" || id.trim() === "") {
50+
return NextResponse.json({ error: "Missing order id" }, { status: 400 });
51+
}
52+
if (!status || typeof status !== "string") {
53+
return NextResponse.json({ error: "Missing order status" }, { status: 400 });
54+
}
55+
56+
try {
57+
await sql`
58+
INSERT INTO transak_orders (
59+
id, user_id, status, crypto_amount, crypto_currency,
60+
fiat_amount, fiat_currency, wallet_address, tx_hash,
61+
created_at, updated_at
62+
)
63+
VALUES (
64+
${id},
65+
${session.userId},
66+
${status},
67+
${cryptoAmount ?? null},
68+
${cryptoCurrency ?? null},
69+
${fiatAmount ?? null},
70+
${fiatCurrency ?? null},
71+
${walletAddress ?? null},
72+
${txHash ?? null},
73+
now(),
74+
now()
75+
)
76+
ON CONFLICT (id) DO UPDATE SET
77+
status = EXCLUDED.status,
78+
crypto_amount = COALESCE(EXCLUDED.crypto_amount, transak_orders.crypto_amount),
79+
tx_hash = COALESCE(EXCLUDED.tx_hash, transak_orders.tx_hash),
80+
updated_at = now()
81+
`;
82+
83+
return NextResponse.json({ ok: true });
84+
} catch (err) {
85+
console.error("[onramp/order] DB error:", err);
86+
return NextResponse.json({ error: "Failed to save order" }, { status: 500 });
87+
}
88+
}

app/api/webhooks/transak/route.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* POST /api/webhooks/transak
3+
*
4+
* Receives server-side order status updates from Transak.
5+
* Verifies the X-Transak-Signature HMAC-SHA256 header, then upserts
6+
* the order into transak_orders.
7+
*
8+
* Setup:
9+
* 1. In the Transak dashboard, set the webhook URL to:
10+
* https://<your-domain>/api/webhooks/transak
11+
* 2. Copy the webhook secret into TRANSAK_WEBHOOK_SECRET env var.
12+
*
13+
* Signature format (Transak):
14+
* X-Transak-Signature: <hex-encoded HMAC-SHA256(secret, rawBody)>
15+
*/
16+
17+
import { createHmac, timingSafeEqual } from "crypto";
18+
import { NextResponse } from "next/server";
19+
import { sql } from "@vercel/postgres";
20+
import type { TransakWebhookPayload } from "@/types/transak";
21+
22+
function verifyTransakSignature(
23+
signature: string,
24+
rawBody: string,
25+
secret: string
26+
): boolean {
27+
const expected = createHmac("sha256", secret)
28+
.update(rawBody)
29+
.digest("hex");
30+
31+
try {
32+
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
33+
} catch {
34+
return false;
35+
}
36+
}
37+
38+
export async function POST(req: Request) {
39+
const rawBody = await req.text();
40+
const signature = req.headers.get("x-transak-signature");
41+
const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET;
42+
43+
if (webhookSecret) {
44+
if (!signature) {
45+
console.error("❌ [transak webhook] Missing X-Transak-Signature header");
46+
return NextResponse.json({ error: "Missing signature" }, { status: 401 });
47+
}
48+
if (!verifyTransakSignature(signature, rawBody, webhookSecret)) {
49+
console.error("❌ [transak webhook] Invalid signature");
50+
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
51+
}
52+
} else {
53+
console.warn(
54+
"⚠️ TRANSAK_WEBHOOK_SECRET not set — skipping signature verification (set it in production)"
55+
);
56+
}
57+
58+
let payload: TransakWebhookPayload;
59+
try {
60+
payload = JSON.parse(rawBody);
61+
} catch {
62+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
63+
}
64+
65+
const order = payload?.webhookData;
66+
if (!order?.id || !order?.status) {
67+
console.error("❌ [transak webhook] Missing order data in payload", payload);
68+
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
69+
}
70+
71+
console.log(`🔔 Transak webhook: order ${order.id}${order.status}`);
72+
73+
try {
74+
// Resolve the StreamFi user by wallet address so we can associate the order.
75+
// wallet_address is the Stellar public key the user provided to Transak.
76+
const userResult = await sql`
77+
SELECT id FROM users WHERE wallet = ${order.walletAddress} LIMIT 1
78+
`;
79+
const userId: string | null =
80+
userResult.rows.length > 0 ? userResult.rows[0].id : null;
81+
82+
await sql`
83+
INSERT INTO transak_orders (
84+
id, user_id, status, crypto_amount, crypto_currency,
85+
fiat_amount, fiat_currency, wallet_address, tx_hash,
86+
created_at, updated_at
87+
)
88+
VALUES (
89+
${order.id},
90+
${userId},
91+
${order.status},
92+
${order.cryptoAmount ?? null},
93+
${order.cryptoCurrency ?? null},
94+
${order.fiatAmount ?? null},
95+
${order.fiatCurrency ?? null},
96+
${order.walletAddress ?? null},
97+
${order.transactionHash ?? null},
98+
now(),
99+
now()
100+
)
101+
ON CONFLICT (id) DO UPDATE SET
102+
status = EXCLUDED.status,
103+
crypto_amount = COALESCE(EXCLUDED.crypto_amount, transak_orders.crypto_amount),
104+
tx_hash = COALESCE(EXCLUDED.tx_hash, transak_orders.tx_hash),
105+
updated_at = now()
106+
`;
107+
108+
console.log(`✅ [transak webhook] Upserted order ${order.id}`);
109+
return NextResponse.json({ received: true });
110+
} catch (err) {
111+
console.error("❌ [transak webhook] DB error:", err);
112+
return NextResponse.json(
113+
{ error: "Failed to process webhook" },
114+
{ status: 500 }
115+
);
116+
}
117+
}
118+
119+
// Health check
120+
export async function GET() {
121+
return NextResponse.json({
122+
status: "ok",
123+
message: "Transak webhook endpoint is active",
124+
});
125+
}

app/dashboard/payout/page.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import useSWR from "swr";
66
import Link from "next/link";
77
import { motion, AnimatePresence } from "framer-motion";
88
import { TipCounter } from "@/components/tipping";
9+
import { AddFundsButton } from "@/components/wallet/AddFundsButton";
10+
import { TRANSAK_ORDER_COMPLETE_EVENT } from "@/hooks/useTransak";
911
import {
1012
Wallet,
1113
Copy,
@@ -85,6 +87,15 @@ export default function PayoutPage() {
8587
return () => clearInterval(interval);
8688
}, [fetchPrice]);
8789

90+
// Refresh balance automatically after a completed Transak order
91+
useEffect(() => {
92+
const handler = () => {
93+
void refreshBalance();
94+
};
95+
window.addEventListener(TRANSAK_ORDER_COMPLETE_EVENT, handler);
96+
return () => window.removeEventListener(TRANSAK_ORDER_COMPLETE_EVENT, handler);
97+
}, [refreshBalance]);
98+
8899
const handleCopy = () => {
89100
if (!walletAddress) {
90101
return;
@@ -146,18 +157,27 @@ export default function PayoutPage() {
146157
Stellar Balance
147158
</span>
148159
</div>
149-
<button
150-
onClick={() => refreshBalance()}
151-
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground"
152-
aria-label="Refresh balance"
153-
>
154-
<RefreshCcw
155-
className={cn(
156-
"w-4 h-4",
157-
balanceLoading && "animate-spin text-highlight"
158-
)}
159-
/>
160-
</button>
160+
<div className="flex items-center gap-2">
161+
{walletAddress && (
162+
<AddFundsButton
163+
walletAddress={walletAddress}
164+
email={privyWallet?.email ?? undefined}
165+
isPrivyUser={isPrivyUser}
166+
/>
167+
)}
168+
<button
169+
onClick={() => refreshBalance()}
170+
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground"
171+
aria-label="Refresh balance"
172+
>
173+
<RefreshCcw
174+
className={cn(
175+
"w-4 h-4",
176+
balanceLoading && "animate-spin text-highlight"
177+
)}
178+
/>
179+
</button>
180+
</div>
161181
</div>
162182

163183
{/* Balance amount */}

components/tipping/TipModal.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { Input } from "@/components/ui/input";
1515
import { Label } from "@/components/ui/label";
1616
import { Loader2, XCircle, AlertTriangle } from "lucide-react";
17+
import { AddFundsButton } from "@/components/wallet/AddFundsButton";
1718
import {
1819
buildTipTransaction,
1920
submitTransaction,
@@ -33,6 +34,8 @@ interface TipModalProps {
3334
recipientPublicKey: string;
3435
recipientAvatar?: string;
3536
senderPublicKey: string;
37+
/** Whether the sender is a Privy custodial user — used to contextualise the "Add Funds" nudge */
38+
isPrivyUser?: boolean;
3639
onSuccess?: (txHash: string, amount: string) => void;
3740
onError?: (error: string) => void;
3841
}
@@ -56,6 +59,7 @@ export function TipModal({
5659
recipientPublicKey,
5760
recipientAvatar,
5861
senderPublicKey,
62+
isPrivyUser = false,
5963
onSuccess,
6064
onError,
6165
}: TipModalProps) {
@@ -75,6 +79,7 @@ export function TipModal({
7579
details?: string;
7680
code?: string;
7781
} | null>(null);
82+
const [isInsufficientBalance, setIsInsufficientBalance] = useState(false);
7883

7984
const { kit } = useStellarWallet();
8085
const fee = calculateFeeEstimate();
@@ -113,6 +118,7 @@ export function TipModal({
113118
setTxHash(null);
114119
setIsConfirmationOpen(false);
115120
setStructuredError(null);
121+
setIsInsufficientBalance(false);
116122
};
117123

118124
const handlePresetClick = (presetAmount: number) => {
@@ -188,6 +194,7 @@ export function TipModal({
188194
setError(
189195
"Insufficient balance. Please ensure you have enough XLM to cover the tip and transaction fee."
190196
);
197+
setIsInsufficientBalance(true);
191198
setTransactionState("error");
192199
if (onError) {
193200
onError("Insufficient balance");
@@ -491,6 +498,19 @@ export function TipModal({
491498
</div>
492499
</div>
493500
)}
501+
502+
{/* Add Funds nudge — shown when balance is too low */}
503+
{isInsufficientBalance && transactionState === "error" && !isConfirmationOpen && (
504+
<div className="flex items-center justify-between gap-3 p-3 bg-highlight/10 border border-highlight/20 rounded-lg">
505+
<p className="text-xs text-muted-foreground leading-relaxed">
506+
Need more XLM? Top up your wallet instantly with fiat.
507+
</p>
508+
<AddFundsButton
509+
walletAddress={senderPublicKey}
510+
isPrivyUser={isPrivyUser}
511+
/>
512+
</div>
513+
)}
494514
</div>
495515

496516
<DialogFooter>

0 commit comments

Comments
 (0)