Skip to content

Commit 7f6ab6a

Browse files
committed
Move withdraw endpoints
1 parent 1a3382c commit 7f6ab6a

File tree

9 files changed

+151
-130
lines changed

9 files changed

+151
-130
lines changed

apps/server/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import cors from "cors";
55
import express from "express";
66
import rateLimit from "express-rate-limit";
77
import helmet from "helmet";
8-
import { appRouter } from "./routers";
98
import { subscribeInvoices } from "./subscribeInvoices";
109
import { createContext } from "./trpc";
10+
import { appRouter } from "./trpcRouters";
11+
import { router as withdrawRouter } from "./withdrawRouter";
1112

1213
(async () => {
1314
const isProd = process.env.NODE_ENV === "production";
@@ -47,6 +48,8 @@ import { createContext } from "./trpc";
4748

4849
subscribeInvoices();
4950

51+
app.use("/api/withdraw", withdrawRouter);
52+
5053
app.use(
5154
"/api",
5255
createExpressMiddleware({

apps/server/src/routers/payments.ts

Lines changed: 0 additions & 126 deletions
This file was deleted.

apps/server/src/trpc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { initTRPC } from "@trpc/server";
22
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
33

4-
export const createContext = ({ req, res: _res }: CreateExpressContextOptions) => ({ req });
4+
export const createContext = ({ req: _req, res: _res }: CreateExpressContextOptions) => ({});
55

66
type Context = Awaited<ReturnType<typeof createContext>>;
77

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { on } from "node:events";
2+
import z from "zod";
3+
import { PAYMENT_SUCCESS_EVENT } from "../constants";
4+
import { ee } from "../eventEmitter";
5+
import { publicProcedure, router } from "../trpc";
6+
7+
const paymentsRouter = router({
8+
paymentUpdate: publicProcedure.input(z.string()).subscription(async function* ({
9+
input: paymentId,
10+
signal,
11+
}) {
12+
for await (const [data] of on(ee, PAYMENT_SUCCESS_EVENT, {
13+
signal,
14+
})) {
15+
if (data === paymentId) {
16+
yield data;
17+
}
18+
}
19+
}),
20+
});
21+
22+
export { paymentsRouter };

apps/server/src/withdrawRouter.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ClaimStatus, prisma } from "@repo/database";
2+
import { getRoutingFee } from "@repo/shared";
3+
import { Mutex, type MutexInterface, withTimeout } from "async-mutex";
4+
import { Router } from "express";
5+
import z from "zod";
6+
import { PAYMENT_SUCCESS_EVENT } from "./constants";
7+
import { ee } from "./eventEmitter";
8+
import { lnGrpcClient, promisifyGrpc, routerGrpcClient } from "./lndClient";
9+
import type { Payment__Output } from "./protos/generated/lnrpc/Payment";
10+
11+
const router = Router();
12+
13+
const mutexes = new Map<string, MutexInterface>();
14+
15+
router.get("/", async (req, res) => {
16+
const { k1, pr } = z.object({ k1: z.string(), pr: z.string() }).parse(req.query);
17+
18+
if (!mutexes.has(k1)) {
19+
mutexes.set(k1, withTimeout(new Mutex(), 15 * 1000)); // todo
20+
}
21+
22+
// biome-ignore lint/style/noNonNullAssertion: It is set on the line above
23+
const mutex = mutexes.get(k1)!;
24+
25+
const release = await mutex.acquire();
26+
27+
const claim = await prisma.claims.findUnique({ where: { id: k1 } });
28+
29+
if (!claim || claim.status !== ClaimStatus.PAID) {
30+
release();
31+
32+
res.json({
33+
status: "ERROR",
34+
reason: "Claim not found, already claimed or not paid for yet.",
35+
});
36+
37+
return;
38+
}
39+
40+
const result = await promisifyGrpc(lnGrpcClient.DecodePayReq.bind(lnGrpcClient), {
41+
payReq: pr,
42+
});
43+
44+
if (!result) {
45+
release();
46+
47+
res.json({
48+
status: "ERROR",
49+
reason: "Invalid pr.",
50+
});
51+
52+
return;
53+
}
54+
55+
if (Number(result.numSatoshis) !== claim.receiverSatsAmount) {
56+
release();
57+
58+
res.json({
59+
status: "ERROR",
60+
reason: `Claim for ${result.numSatoshis} sats doesn't match the expected claim of ${claim.receiverSatsAmount} sats.`,
61+
});
62+
63+
return;
64+
}
65+
66+
res.json({ status: "OK" });
67+
68+
const stream = routerGrpcClient.SendPaymentV2({
69+
paymentRequest: pr,
70+
feeLimitSat: getRoutingFee(claim.receiverSatsAmount),
71+
timeoutSeconds: 15, // todo
72+
});
73+
74+
stream.on("data", async (data: Payment__Output) => {
75+
if (data.status === "FAILED") {
76+
release();
77+
}
78+
79+
if (data.status === "SUCCEEDED") {
80+
await prisma.claims.update({
81+
where: { id: k1 },
82+
data: { status: ClaimStatus.CLAIMED },
83+
});
84+
85+
ee.emit(PAYMENT_SUCCESS_EVENT, claim.paymentRequest);
86+
87+
release();
88+
}
89+
});
90+
91+
stream.on("error", (err) => {
92+
console.log("Error happened during payment stream: ", err);
93+
94+
release();
95+
});
96+
});
97+
98+
router.get("/:claimId", async (req, res) => {
99+
const claim = await prisma.claims.findUnique({ where: { id: req.params.claimId } });
100+
101+
if (!claim || claim.status !== ClaimStatus.PAID) {
102+
res.json({
103+
status: "ERROR",
104+
reason: "Claim not found, already claimed or not paid for yet.",
105+
});
106+
107+
return;
108+
}
109+
110+
const receiverMilliSats = claim.receiverSatsAmount * 1000;
111+
112+
res.json({
113+
tag: "withdrawRequest",
114+
callback: "https://stackorange.com/api/withdraw",
115+
k1: claim.id,
116+
defaultDescription: "Orange pill from stackorange.com",
117+
minWithdrawable: receiverMilliSats,
118+
maxWithdrawable: receiverMilliSats,
119+
});
120+
});
121+
122+
export { router };

apps/web/src/containers/Claim.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const ClaimComponent = () => {
4848
}),
4949
);
5050

51-
const withdrawLink = `https://stackorange.com/api/payments.getWithdrawInfo?input="${id}"`;
51+
const withdrawLink = `https://stackorange.com/api/withdraw/${id}`;
5252

5353
const withdrawLinkLnurl = bech32
5454
.encode("lnurl", bech32.toWords(Buffer.from(withdrawLink, "utf8")), 1023)

apps/web/src/trpc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
splitLink,
88
} from "@trpc/client";
99
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
10-
import type { AppRouter } from "../../server/src/routers";
10+
import type { AppRouter } from "../../server/src/trpcRouters";
1111
import { API_URL } from "./constants";
1212

1313
export const queryClient = new QueryClient();

0 commit comments

Comments
 (0)